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 Action? OnNavigateToGroups { get; set; } public bool HasNoExams => Rows.Count == 0; 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." : ""; OnPropertyChanged(nameof(HasNoExams)); 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 GoToGroups() => OnNavigateToGroups?.Invoke(); [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", }; } }