From 7fbf035cbbfb4d1f87a140aadc3b8e566e8e6007 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sat, 29 Aug 2026 01:27:18 +0200 Subject: [PATCH] =?UTF-8?q?Klausuren-Hauptseite:=20gruppen=C3=BCbergreifen?= =?UTF-8?q?de=20Liste=20mit=20Priorit=C3=A4ts-Score=20statt=20Datum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar-Klausuren war bisher ein Placeholder. Neue Seite zeigt alle Klausuren des Schuljahres sortiert nach abgeleiteter Dringlichkeit (Korrekturfortschritt statt manuellem Status), mit Detailbereich, Parallelkurs-Umschalter und Notenspiegel. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Models/Exam.cs | 6 + .../Services/ExamPriorityService.cs | 67 +++++ .../ExamGradingDialogViewModelTests.cs | 65 +++++ .../ExamsOverviewViewModelTests.cs | 89 ++++++ LehrerApp.Desktop/AppBootstrapper.cs | 2 + .../ViewModels/DashboardViewModel.cs | 8 +- .../Exams/ExamsOverviewViewModel.cs | 253 ++++++++++++++++++ .../Groups/ExamGradingViewModels.cs | 11 + .../ViewModels/Groups/ExamViewModels.cs | 7 + .../ViewModels/MainWindowViewModel.cs | 11 +- .../Views/Exams/ExamsOverviewView.axaml | 222 +++++++++++++++ .../Views/Exams/ExamsOverviewView.axaml.cs | 63 +++++ .../Views/Groups/ExamGradingDialog.axaml | 3 + LehrerApp.Desktop/Views/MainWindow.axaml | 5 + LehrerApp.Tests/ExamPriorityServiceTests.cs | 102 +++++++ TODO.md | 42 +++ 16 files changed, 950 insertions(+), 6 deletions(-) create mode 100644 LehrerApp.Core/Services/ExamPriorityService.cs create mode 100644 LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs create mode 100644 LehrerApp.Desktop.Tests/ExamsOverviewViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml create mode 100644 LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs create mode 100644 LehrerApp.Tests/ExamPriorityServiceTests.cs diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs index 7e830c4..2303812 100644 --- a/LehrerApp.Core/Models/Exam.cs +++ b/LehrerApp.Core/Models/Exam.cs @@ -13,6 +13,12 @@ public class Exam public DateOnly? ReturnedAt { get; set; } public Niveau? Niveau { get; set; } public string? Notes { get; set; } + public DateOnly? ApprovalGrantedAt { get; set; } + public DateOnly? AnnouncedAt { get; set; } + /// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter + /// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren + /// (siehe ExamDialogViewModel.Save). + public Guid? SharedExamGroupId { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/LehrerApp.Core/Services/ExamPriorityService.cs b/LehrerApp.Core/Services/ExamPriorityService.cs new file mode 100644 index 0000000..5de944a --- /dev/null +++ b/LehrerApp.Core/Services/ExamPriorityService.cs @@ -0,0 +1,67 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen +/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald +/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von +/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren- +/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen. +public static class ExamCorrectionCounter +{ + public static (int Expected, int Evaluated) Count(Exam exam, List groupMemberships, + List examResults) + { + var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date)); + var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0); + return (expected, Math.Min(evaluated, expected)); + } +} + +public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned } + +public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score); + +/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt +/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft" +/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe +/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer +/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der +/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben. +public static class ExamPriorityService +{ + public const int StuckAfterDays = 21; + private const int CorrectionGraceDays = 3; + + public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today) + { + if (exam.Status == ExamStatus.Returned) + return new ExamPriorityInfo(ExamListStatus.Returned, -100); + + if (exam.Status == ExamStatus.Graded) + return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300); + + if (exam.Status == ExamStatus.Planned) + { + var daysUntil = exam.Date.DayNumber - today.DayNumber; + return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150)); + } + + // Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung. + var daysSince = today.DayNumber - exam.Date.DayNumber; + + if (evaluated <= 0) + { + var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince; + return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score); + } + + if (expected > 0 && evaluated >= expected) + return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300); + + if (daysSince > StuckAfterDays) + return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90); + + return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3); + } +} diff --git a/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs new file mode 100644 index 0000000..a36039c --- /dev/null +++ b/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs @@ -0,0 +1,65 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass +/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen. +public sealed class ExamGradingDialogViewModelTests +{ + [Fact] + public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" }; + var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" }; + var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" }; + var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]); + var memberships = new FakeMemberships( + [ + new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id }, + new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id }, + new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id }, + ]); + var exam = new Exam + { + GroupId = group.Id, + Title = "Klausur 1", + Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], + GradingKey = GradingService.DefaultKey1To6(), + }; + var results = new FakeResults(); + + var vm = new ExamGradingDialogViewModel(results, students, memberships, + new GradingService(), exam, group.Id); + + var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id); + rowWithPoints.Cells[0].TrySetValue(7); + + vm.MarkRemainingAbsentCommand.Execute(null); + + Assert.False(rowWithPoints.Absent); + Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent); + Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent); + } + + [Fact] + public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var student = new Student { FirstName = "Ben", LastName = "Cordes" }; + var students = new FakeStudents([student]); + var memberships = new FakeMemberships( + [new GroupMembership { GroupId = group.Id, StudentId = student.Id }]); + var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() }; + var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships, + new GradingService(), exam, group.Id); + + vm.Rows[0].Absent = true; + vm.MarkRemainingAbsentCommand.Execute(null); + + Assert.True(vm.Rows[0].Absent); + } +} diff --git a/LehrerApp.Desktop.Tests/ExamsOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamsOverviewViewModelTests.cs new file mode 100644 index 0000000..4238eba --- /dev/null +++ b/LehrerApp.Desktop.Tests/ExamsOverviewViewModelTests.cs @@ -0,0 +1,89 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Exams; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht, +/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5). +public sealed class ExamsOverviewViewModelTests +{ + private static ExamsOverviewViewModel BuildVm(List groups, List exams, + FakeMemberships? memberships = null, FakeResults? results = null) => + new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups), + memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService()); + + [Fact] + public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur() + { + var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() }; + var today = DateOnly.FromDateTime(DateTime.Today); + var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) }; + var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) }; + + var vm = BuildVm([group], [overdue, planned]); + vm.LoadCommand.Execute(null); + + Assert.Equal("Alte Klausur", vm.Rows[0].Title); + Assert.Equal("Neue Klausur", vm.Rows[1].Title); + } + + [Fact] + public void Load_ParallelkurseWerdenAlsGeschwisterErkannt() + { + var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() }; + var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear }; + var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) }; + var duplicate = new Exam + { + GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned, + Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id, + }; + + var vm = BuildVm([groupA, groupB], [original, duplicate]); + vm.LoadCommand.Execute(null); + + vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id); + Assert.True(vm.SelectedRow.HasSibling); + Assert.Single(vm.Siblings); + Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id); + } + + [Fact] + public void ToggleApproval_SetztUndLöschtDatum() + { + var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() }; + var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) }; + var vm = BuildVm([group], [exam]); + vm.LoadCommand.Execute(null); + vm.SelectedRow = vm.Rows[0]; + + vm.ToggleApprovalCommand.Execute(null); + Assert.NotNull(exam.ApprovalGrantedAt); + Assert.StartsWith("Genehmigt am", vm.ApprovalLabel); + + vm.ToggleApprovalCommand.Execute(null); + Assert.Null(exam.ApprovalGrantedAt); + Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel); + } + + [Fact] + public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus() + { + var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() }; + var today = DateOnly.FromDateTime(DateTime.Today); + var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) }; + var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) }; + var vm = BuildVm([group], [conducted, returned]); + vm.LoadCommand.Execute(null); + + vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id); + Assert.True(vm.ShowCorrectionProgress); + Assert.False(vm.ShowGradeSummary); + + vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id); + Assert.False(vm.ShowCorrectionProgress); + Assert.True(vm.ShowGradeSummary); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index f4f046a..dd7c620 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -6,6 +6,7 @@ using LehrerApp.Data.Repositories; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Desktop.ViewModels.Exams; using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Settings; @@ -306,6 +307,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Transient: neue Instanz pro Navigation (für Detailseiten) services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 22eb15d..916ca29 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -386,12 +386,10 @@ public partial class DashboardViewModel : ObservableObject .Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) .OrderBy(e => e.Date)) { - var expected = _memberships.GetByGroup(group.Id) - .Count(m => GroupMembershipService.IsActiveOn(m, exam.Date)); - var evaluated = _examResults.GetByExam(exam.Id) - .Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0); + var (expected, evaluated) = ExamCorrectionCounter.Count(exam, + _memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id)); OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, - group.Name, exam.Date, Math.Min(evaluated, expected), expected, today)); + group.Name, exam.Date, evaluated, expected, today)); } } diff --git a/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs new file mode 100644 index 0000000..3b20bba --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs @@ -0,0 +1,253 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Exams; + +/// Klausuren-Hauptseite (Sidebar "Klausuren"): gruppenübergreifende Liste aller Klausuren des +/// aktuellen Schuljahres, sortiert nach einem unsichtbaren Prioritäts-Score +/// (`ExamPriorityService`) statt nach Datum — unbearbeitete/überfällige Korrekturen oben, +/// erledigte unten. Oben ein Detailbereich zur ausgewählten Klausur, der sich je nach Status +/// unterscheidet (Korrekturfortschritt vs. Notenspiegel), inklusive Umschalter für Parallelkurse +/// (gleiche Arbeit, mehrere Kurse — siehe `Exam.SharedExamGroupId`). +public partial class ExamsOverviewViewModel : ObservableObject +{ + private readonly IExamRepository _exams; + private readonly IExamResultRepository _examResults; + private readonly IGroupRepository _groups; + private readonly IGroupMembershipRepository _memberships; + private readonly GradingService _grading; + private readonly SchoolYearService _schoolYear; + + public ObservableCollection Rows { get; } = []; + public ObservableCollection Siblings { get; } = []; + public ObservableCollection DetailGradeDistribution { get; } = []; + + [ObservableProperty] private ExamListRowViewModel? _selectedRow; + [ObservableProperty] private bool _hasSelection; + [ObservableProperty] private string _emptyHint = ""; + + [ObservableProperty] private bool _showCorrectionProgress; + [ObservableProperty] private string _progressLabel = ""; + [ObservableProperty] private double _progressBarWidth; + [ObservableProperty] private bool _showGradeSummary; + [ObservableProperty] private string _averageLabel = ""; + [ObservableProperty] private string _approvalLabel = ""; + [ObservableProperty] private string _announcementLabel = ""; + + public Func? OnGradeExam { get; set; } + public Func? OnEvaluateExam { get; set; } + public Action? OnNavigateToGroup { get; set; } + + public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults, + IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading, + SchoolYearService schoolYear) + { + _exams = exams; _examResults = examResults; _groups = groups; + _memberships = memberships; _grading = grading; _schoolYear = schoolYear; + } + + [RelayCommand] + public void Load() + { + var selectedId = SelectedRow?.Exam.Id; + Rows.Clear(); + + var groups = _groups.GetBySchoolYear(_schoolYear.CurrentSchoolYear()); + var today = DateOnly.FromDateTime(DateTime.Today); + + var rows = new List(); + foreach (var group in groups) + { + var groupMemberships = _memberships.GetByGroup(group.Id); + foreach (var exam in _exams.GetByGroup(group.Id)) + { + var (expected, evaluated) = ExamCorrectionCounter.Count(exam, groupMemberships, + _examResults.GetByExam(exam.Id)); + var info = ExamPriorityService.Evaluate(exam, expected, evaluated, today); + rows.Add(new ExamListRowViewModel(exam, group, expected, evaluated, info, today)); + } + } + + // Parallelkurse markieren, damit die Liste einen Verknüpfungs-Hinweis zeigen kann. + foreach (var group in rows.GroupBy(r => r.Exam.SharedExamGroupId ?? r.Exam.Id)) + { + if (group.Count() <= 1) continue; + foreach (var row in group) row.HasSibling = true; + } + + foreach (var row in rows.OrderByDescending(r => r.Score)) + Rows.Add(row); + + EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : ""; + SelectedRow = selectedId is { } id + ? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault() + : Rows.FirstOrDefault(); + } + + partial void OnSelectedRowChanged(ExamListRowViewModel? value) => UpdateDetail(value); + + private void UpdateDetail(ExamListRowViewModel? row) + { + HasSelection = row is not null; + Siblings.Clear(); + DetailGradeDistribution.Clear(); + ShowCorrectionProgress = false; + ShowGradeSummary = false; + if (row is null) return; + + ApprovalLabel = row.Exam.ApprovalGrantedAt is { } a + ? $"Genehmigt am {a.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}" + : "Genehmigung noch ausstehend"; + AnnouncementLabel = row.Exam.AnnouncedAt is { } n + ? $"Angekündigt am {n.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}" + : "Noch nicht angekündigt"; + + if (row.HasSibling) + { + var anchor = row.Exam.SharedExamGroupId ?? row.Exam.Id; + foreach (var sibling in Rows.Where(r => r != row && (r.Exam.SharedExamGroupId ?? r.Exam.Id) == anchor)) + Siblings.Add(sibling); + } + + ShowCorrectionProgress = row.Status is ExamListStatus.AwaitingCorrection + or ExamListStatus.CorrectionInProgress or ExamListStatus.CorrectionStuck; + if (ShowCorrectionProgress) + { + ProgressLabel = $"{row.Evaluated} / {row.Expected} korrigiert"; + var fraction = row.Expected <= 0 ? 0 : Math.Clamp((double)row.Evaluated / row.Expected, 0, 1); + ProgressBarWidth = fraction * 240.0; + } + + ShowGradeSummary = row.Status is ExamListStatus.AwaitingReturn or ExamListStatus.Returned; + if (ShowGradeSummary) LoadGradeSummary(row); + } + + private void LoadGradeSummary(ExamListRowViewModel row) + { + var grades = _examResults.GetByExam(row.Exam.Id) + .Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade)) + .Select(r => r.Grade!).ToList(); + if (grades.Count == 0) { AverageLabel = "Noch keine Noten erfasst."; return; } + + AverageLabel = "Ø " + _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList()) + .ToString("0.00", CultureInfo.InvariantCulture); + + var counts = grades.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count()); + var maxCount = counts.Count == 0 ? 0 : counts.Values.Max(); + foreach (var entry in row.Exam.GradingKey.OrderByDescending(e => e.MinPercent)) + { + counts.TryGetValue(entry.Grade, out var count); + DetailGradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount)); + } + } + + [RelayCommand] + private void SelectExam(ExamListRowViewModel row) => SelectedRow = row; + + [RelayCommand] + private async Task GradeSelected() + { + if (SelectedRow is null || OnGradeExam is null) return; + await OnGradeExam(SelectedRow.Exam); + Load(); + } + + [RelayCommand] + private async Task EvaluateSelected() + { + if (SelectedRow is null || OnEvaluateExam is null) return; + await OnEvaluateExam(SelectedRow.Exam); + Load(); + } + + [RelayCommand] + private void GoToGroup() + { + if (SelectedRow is null) return; + OnNavigateToGroup?.Invoke(SelectedRow.GroupId); + } + + [RelayCommand] + private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt, + (e, v) => e.ApprovalGrantedAt = v); + + [RelayCommand] + private void ToggleAnnouncement() => ToggleDate(SelectedRow?.Exam, e => e.AnnouncedAt, + (e, v) => e.AnnouncedAt = v); + + private void ToggleDate(Exam? exam, Func get, Action set) + { + if (exam is null) return; + set(exam, get(exam) is null ? DateOnly.FromDateTime(DateTime.Today) : null); + exam.UpdatedAt = DateTime.UtcNow; + _exams.Save(exam); + UpdateDetail(SelectedRow); + } +} + +// ── Zeile in der Klausurliste ──────────────────────────────────────────────── + +public class ExamListRowViewModel +{ + public Exam Exam { get; } + public Guid GroupId { get; } + public string Title { get; } + public string GroupLabel { get; } + public int Expected { get; } + public int Evaluated { get; } + public ExamListStatus Status { get; } + public double Score { get; } + public string StatusLabel { get; } + public string SubLabel { get; } + public bool HasSibling { get; set; } + + public bool IsOk { get; } + public bool IsInfo { get; } + public bool IsWarning { get; } + public bool IsDanger { get; } + + public ExamListRowViewModel(Exam exam, LearningGroup group, int expected, int evaluated, + ExamPriorityInfo info, DateOnly today) + { + Exam = exam; GroupId = group.Id; Title = exam.Title; GroupLabel = group.Name; + Expected = expected; Evaluated = evaluated; Status = info.Status; Score = info.Score; + + (StatusLabel, SubLabel, IsOk, IsInfo, IsWarning, IsDanger) = Status switch + { + ExamListStatus.Planned => ("Geplant", $"geplant für {RelativeDay(exam.Date, today)}", + false, false, false, false), + ExamListStatus.AwaitingCorrection when today.DayNumber - exam.Date.DayNumber <= 3 => + ("Korrektur ausstehend", $"geschrieben {RelativeDay(exam.Date, today)}", + false, false, true, false), + ExamListStatus.AwaitingCorrection => + ("überfällig", $"geschrieben {RelativeDay(exam.Date, today)}", false, false, false, true), + ExamListStatus.CorrectionInProgress => + ("Korrektur läuft", $"{evaluated}/{expected} korrigiert", false, false, true, false), + ExamListStatus.CorrectionStuck => + ("hängt fest", $"{evaluated}/{expected} korrigiert", false, false, false, false), + ExamListStatus.AwaitingReturn => ("Korrigiert", "Rückgabe aussteht", false, true, false, false), + ExamListStatus.Returned => ("Abgeschlossen", + exam.ReturnedAt is { } r ? $"zurückgegeben {r:dd.MM.yyyy}" : "zurückgegeben", true, false, false, false), + _ => ("", "", false, false, false, false), + }; + } + + private static string RelativeDay(DateOnly date, DateOnly today) + { + var diff = date.DayNumber - today.DayNumber; + return diff switch + { + 0 => "heute", + 1 => "morgen", + -1 => "gestern", + > 1 => $"in {diff} Tagen", + _ => $"vor {-diff} Tagen", + }; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs index 846688f..e05e4e9 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; @@ -52,6 +53,16 @@ public partial class ExamGradingDialogViewModel : ObservableObject private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id)); + /// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren + /// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand + /// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte + /// Zeilen an — bereits eingetragene Punkte/Kommentare bleiben unangetastet. + [RelayCommand] + private void MarkRemainingAbsent() + { + foreach (var row in Rows.Where(r => !r.Absent && r.Cells.All(c => c.Value is null))) + row.Absent = true; + } } // ── Zeile im Punkteraster ────────────────────────────────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs index 0c83b17..9a27cd3 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -21,6 +21,7 @@ public partial class ExamDialogViewModel : ObservableObject private readonly int _gradeLevel; private readonly GradingSystem _gradingSystem; private readonly Exam? _editingExam; + private readonly Exam? _duplicateSource; private readonly string _subjectName; [ObservableProperty] private string _title = ""; @@ -68,6 +69,7 @@ public partial class ExamDialogViewModel : ObservableObject _groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel; _gradingSystem = gradingSystem; _editingExam = editingExam; + _duplicateSource = duplicateSource; _subjectName = defaultSubjectName; IsDifferentiated = isDifferentiated; @@ -304,6 +306,11 @@ public partial class ExamDialogViewModel : ObservableObject Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName); Result.Tasks = Tasks.Select(t => t.ToModel()).ToList(); Result.GradingKey = gradingKey; + // Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die + // Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst + // angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter. + if (_duplicateSource is not null) + Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id; _exams.Save(Result); } } diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 0703b1d..c53c09f 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Desktop.ViewModels.Exams; using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Settings; @@ -90,6 +91,7 @@ public partial class MainWindowViewModel : ObservableObject case WorkloadViewModel vm: vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break; case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break; + case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break; case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break; // Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht // laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog. @@ -119,7 +121,7 @@ public partial class MainWindowViewModel : ObservableObject NavItem.Dashboard => GetDashboard(), NavItem.Groups => _services.GetRequiredService(), NavItem.Students => GetStudents(), - NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" }, + NavItem.Exams => GetExams(), NavItem.Planner => GetTimetable(), NavItem.Workload => GetWorkload(), NavItem.ClassTeacher => GetClassTeacherOverview(), @@ -162,6 +164,13 @@ public partial class MainWindowViewModel : ObservableObject return workload; } + private ExamsOverviewViewModel GetExams() + { + var exams = _services.GetRequiredService(); + exams.LoadCommand.Execute(null); + return exams; + } + private ClassTeacherOverviewViewModel GetClassTeacherOverview() { var vm = _services.GetRequiredService(); diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml new file mode 100644 index 0000000..f8c3305 --- /dev/null +++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs new file mode 100644 index 0000000..1434c07 --- /dev/null +++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels; +using LehrerApp.Desktop.ViewModels.Exams; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.Views.Groups; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop.Views.Exams; + +public partial class ExamsOverviewView : UserControl +{ + private const int GroupDetailKlausurenTabIndex = 4; + + public ExamsOverviewView() => InitializeComponent(); + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (DataContext is ExamsOverviewViewModel vm) + { + vm.OnGradeExam = ShowGradeExamDialog; + vm.OnEvaluateExam = ShowEvaluateExamDialog; + vm.OnNavigateToGroup = groupId => App.Services.GetRequiredService() + .NavigateToGroupDetail(groupId, GroupDetailKlausurenTabIndex); + } + } + + private async Task ShowGradeExamDialog(Exam exam) + { + var group = App.Services.GetRequiredService().GetById(exam.GroupId); + if (group is null) return; + + var dialogVm = new ExamGradingDialogViewModel( + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + exam, group.Id); + + var dialog = new ExamGradingDialog { DataContext = dialogVm }; + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner is not null) await dialog.ShowDialog(owner); + } + + private async Task ShowEvaluateExamDialog(Exam exam) + { + var group = App.Services.GetRequiredService().GetById(exam.GroupId); + if (group is null) return; + + var dialogVm = new ExamEvaluationDialogViewModel( + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + exam, group.GradingSystem); + + var dialog = new ExamEvaluationDialog { DataContext = dialogVm }; + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner is not null) await dialog.ShowDialog(owner); + } +} diff --git a/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml index 90f3d78..e359db8 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml @@ -15,6 +15,9 @@ IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> +