using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels.Students; using System.Collections.ObjectModel; using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Groups; // ── Schüler-Leistungsüberblick + Zielnoten-Rechner (Nutzer-Feedback) ───────────── // // Zeigt die Leistungen eines einzelnen Schülers in einem Kurs im Überblick — Klausuren, // Mitarbeit- und sonstige Noten, die daraus berechnete Zeugnisnote sowie zwei Rechner // ("Zielnote": was brauche ich noch? / "Was-wäre-wenn": wie wirkt sich eine zusätzliche // Klausurnote aus?). Bewusst pro Kurs statt fächerübergreifend, da Gewichtungsschema und // Notensystem am Kurs hängen. Zwei Modi über IsTeacherMode: im Bewertermodus zusätzlich // Kursdurchschnitt je Klausur und Bearbeitbarkeit der Mitarbeit-/Sonstige-Noten; im // Schülermodus ausschließlich die eigenen Werte, rein lesend — gedacht, um den Bildschirm im // Gespräch umzudrehen. Exam-Noten bleiben bewusst nur lesbar: eine Korrektur läuft weiterhin // über den bestehenden ExamGradingDialog, der die Punkte-Struktur konsistent hält — direktes // Überschreiben von Exam.Grade hier könnte mit ExamResult.Points/TotalPoints auseinanderlaufen. public partial class StudentPerformanceOverviewViewModel : ObservableObject { private readonly IGradeRepository _grades; private readonly IExamRepository _exams; private readonly IExamResultRepository _results; private readonly IGroupMembershipRepository _memberships; private readonly IGradingSchemeRepository _schemes; private readonly GradingService _grading; private readonly Guid _groupId; private readonly Guid _studentId; private readonly GroupType _groupType; private readonly GradingSystem _gradingSystem; private readonly string _schoolYear; private GradingScheme _scheme = new(); private double? _examsAverage; private double? _participationAverage; private double? _otherAverage; private List<(string Grade, double Weight)> _examGradesRaw = []; private List<(string Grade, double Weight)> _participationGradesRaw = []; private List<(string Grade, double Weight)> _otherGradesRaw = []; public string StudentName { get; } public string GroupLabel { get; } [ObservableProperty] private ParticipationPeriodOption _selectedPeriod; [ObservableProperty] private bool _isTeacherMode = true; [ObservableProperty] private string _schemeSummary = ""; [ObservableProperty] private string? _examsAverageDisplay; [ObservableProperty] private string? _participationAverageDisplay; [ObservableProperty] private string? _otherAverageDisplay; [ObservableProperty] private string _currentReportGradeDisplay = ""; [ObservableProperty] private string _targetGradeText = ""; [ObservableProperty] private GradeBucketKind _targetBucket = GradeBucketKind.Exams; [ObservableProperty] private string _targetResultDisplay = ""; [ObservableProperty] private string _whatIfExamGradeText = ""; [ObservableProperty] private string _whatIfResultDisplay = ""; public List PeriodOptions { get; } = [ new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"), new(ParticipationPeriod.H1, "1. Halbjahr"), new(ParticipationPeriod.H2, "2. Halbjahr"), ]; public string[] TargetBucketOptions { get; } = ["Klausuren", "Mitarbeit", "Sonstige"]; public string TargetBucketName { get => TargetBucket switch { GradeBucketKind.Participation => "Mitarbeit", GradeBucketKind.Other => "Sonstige", _ => "Klausuren", }; set => TargetBucket = value switch { "Mitarbeit" => GradeBucketKind.Participation, "Sonstige" => GradeBucketKind.Other, _ => GradeBucketKind.Exams, }; } public ObservableCollection ExamRows { get; } = []; /// Eigener Verlauf (Nutzer-Feedback): dieselbe Balken-Sparkline wie die bestehende /// Notenentwicklung im Schülerdetail (2.5), hier nur auf die Klausuren dieses Kurses /// beschränkt statt aller Lerngruppen — passt zum Kursdurchschnitt-Vergleich je Klausur. public StudentGradeHistoryGroup ExamHistory { get; } = new("Klausurverlauf"); public ObservableCollection ParticipationRows { get; } = []; public ObservableCollection OtherRows { get; } = []; public StudentPerformanceOverviewViewModel(IGradeRepository grades, IExamRepository exams, IExamResultRepository results, IGroupMembershipRepository memberships, IGradingSchemeRepository schemes, GradingService grading, Guid groupId, Guid studentId, GroupType groupType, GradingSystem gradingSystem, string studentName, string groupLabel, string schoolYear) { _grades = grades; _exams = exams; _results = results; _memberships = memberships; _schemes = schemes; _grading = grading; _groupId = groupId; _studentId = studentId; _groupType = groupType; _gradingSystem = gradingSystem; _schoolYear = schoolYear; StudentName = studentName; GroupLabel = groupLabel; _selectedPeriod = PeriodOptions[0]; Recompute(); } partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute(); public string ModeButtonLabel => IsTeacherMode ? "Modus: Bewerter" : "Modus: Schüler"; partial void OnIsTeacherModeChanged(bool value) => OnPropertyChanged(nameof(ModeButtonLabel)); [RelayCommand] private void ToggleMode() => IsTeacherMode = !IsTeacherMode; private GradingScheme ResolveScheme() => _schemes.GetByGroup(_groupId) ?? _schemes.GetDefaultForType(_groupType) ?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }; private void Recompute() { _scheme = ResolveScheme(); SchemeSummary = $"Klausuren {_scheme.ExamsPercent:0.#} % · Mitarbeit {_scheme.ParticipationPercent:0.#} % · " + $"Sonstige {_scheme.OtherPercent:0.#} %"; var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, SelectedPeriod.Period switch { ParticipationPeriod.H1 => SchoolYearPeriodKind.H1, ParticipationPeriod.H2 => SchoolYearPeriodKind.H2, _ => SchoolYearPeriodKind.FullYear, }); var membership = _memberships.GetByGroup(_groupId).FirstOrDefault(m => m.StudentId == _studentId); // ── Klausuren ───────────────────────────────────────────────────── ExamRows.Clear(); ExamHistory.Points.Clear(); _examGradesRaw = []; int? previousNoteEquivalent = null; foreach (var exam in _exams.GetByGroup(_groupId) .Where(e => e.Date >= periodFrom && e.Date <= periodTo) .Where(e => membership is null || GroupMembershipService.IsActiveOn(membership, e.Date)) .Where(e => !e.Niveau.HasValue || membership?.Niveau == e.Niveau) .OrderBy(e => e.Date)) { var allResults = _results.GetByExam(exam.Id); var own = allResults.FirstOrDefault(r => r.StudentId == _studentId); string? ownGrade = own is { Absent: false, Grade: not null } ? own.Grade : null; if (ownGrade is not null) _examGradesRaw.Add((ownGrade, 1.0)); var classGrades = allResults.Where(r => !r.Absent && r.Grade is not null) .Select(r => (r.Grade!, 1.0)).ToList(); var classAverage = classGrades.Count > 0 ? _grading.WeightedAverage(classGrades) : (double?)null; ExamRows.Add(new StudentExamRow(exam.Title, exam.Date, ownGrade, classAverage.HasValue ? FormatGrade(classAverage.Value) : "–")); // Gleiche Balken-Sparkline wie die bestehende Notenentwicklung (2.5): Rohwert auf die // 1..6-Notenäquivalent-Achse abbilden, damit Grades1To6 und Points0To15 dieselbe // Balkenhöhen-Formel nutzen können. int? noteEquivalent = ownGrade is not null && int.TryParse(ownGrade, out var raw) ? (_gradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw))) : null; var isFailing = noteEquivalent is >= 5; var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue && noteEquivalent.Value - previousNoteEquivalent.Value >= 1; var warnings = new List(); if (isDrop) warnings.Add("Abfall um ≥ 1 Note"); if (isFailing) warnings.Add("Versetzungsgefährdung"); ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, exam.Title, ownGrade ?? "–", noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings))); if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent; } _examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null; // ── Mitarbeit & Sonstige ────────────────────────────────────────── var allGrades = _grades.GetByStudentAndGroup(_studentId, _groupId) .Where(g => g.Date >= periodFrom && g.Date <= periodTo) .Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)) .OrderBy(g => g.Date).ToList(); ParticipationRows.Clear(); _participationGradesRaw = []; foreach (var g in allGrades.Where(g => g.Category == GradeCategory.Participation)) { _participationGradesRaw.Add((g.Value, g.Weight)); ParticipationRows.Add(new StudentSimpleGradeRow(g, SaveGrade)); } _participationAverage = _participationGradesRaw.Count > 0 ? _grading.WeightedAverage(_participationGradesRaw) : null; OtherRows.Clear(); _otherGradesRaw = []; foreach (var g in allGrades.Where(g => g.Category != GradeCategory.Participation)) { _otherGradesRaw.Add((g.Value, g.Weight)); OtherRows.Add(new StudentSimpleGradeRow(g, SaveGrade)); } _otherAverage = _otherGradesRaw.Count > 0 ? _grading.WeightedAverage(_otherGradesRaw) : null; ExamsAverageDisplay = _examsAverage.HasValue ? FormatGrade(_examsAverage.Value) : null; ParticipationAverageDisplay = _participationAverage.HasValue ? FormatGrade(_participationAverage.Value) : null; OtherAverageDisplay = _otherAverage.HasValue ? FormatGrade(_otherAverage.Value) : null; var reportGrade = _grading.CalculateReportGrade(_examGradesRaw, _participationGradesRaw, _otherGradesRaw, _scheme, _gradingSystem, RoundingRule.Commercial); CurrentReportGradeDisplay = reportGrade is null ? "Noch nicht berechenbar — keine Werte im gewählten Zeitraum." : $"Note {reportGrade}"; RecomputeTarget(); RecomputeWhatIf(); } private void SaveGrade(Grade grade) { _grades.Save(grade); Recompute(); } partial void OnTargetGradeTextChanged(string value) => RecomputeTarget(); partial void OnTargetBucketChanged(GradeBucketKind value) { OnPropertyChanged(nameof(TargetBucketName)); RecomputeTarget(); } private void RecomputeTarget() { if (!TryParseGrade(TargetGradeText, out var target)) { TargetResultDisplay = ""; return; } var result = ReportGradeTargetCalculator.SolveRequiredAverage(target, TargetBucket, _examsAverage, _participationAverage, _otherAverage, _scheme, _gradingSystem); TargetResultDisplay = result switch { null => $"{TargetBucketName} fließt laut Gewichtungsschema nicht in die Zeugnisnote ein.", { IsAchievable: true } r => $"Nötiger Schnitt in {TargetBucketName}: {FormatGrade(r.RequiredAverage)}", { IsAchievable: false } r => $"Mit den übrigen Bereichen rechnerisch nicht mehr erreichbar (bräuchte {FormatGrade(r.RequiredAverage)}).", }; } partial void OnWhatIfExamGradeTextChanged(string value) => RecomputeWhatIf(); private void RecomputeWhatIf() { if (string.IsNullOrWhiteSpace(WhatIfExamGradeText)) { WhatIfResultDisplay = ""; return; } var hypothetical = new List<(string Grade, double Weight)>(_examGradesRaw) { (WhatIfExamGradeText.Trim(), 1.0) }; var result = _grading.CalculateReportGrade(hypothetical, _participationGradesRaw, _otherGradesRaw, _scheme, _gradingSystem, RoundingRule.Commercial); WhatIfResultDisplay = result is null ? "Ungültige Note." : $"Zeugnisnote mit dieser zusätzlichen Klausurnote: {result}"; } private static bool TryParseGrade(string text, out double value) => double.TryParse(text.Trim().Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out value); private static string FormatGrade(double value) => value.ToString("0.0", CultureInfo.InvariantCulture); } public class StudentExamRow(string title, DateOnly date, string? ownGrade, string classAverageDisplay) { public string Title { get; } = title; public string DateDisplay { get; } = date.ToString("dd.MM.yyyy"); public string OwnGradeDisplay { get; } = ownGrade ?? "–"; public string ClassAverageDisplay { get; } = classAverageDisplay; } public partial class StudentSimpleGradeRow : ObservableObject { private readonly Grade _grade; private readonly Action _save; public string CategoryLabel { get; } public string DateDisplay { get; } [ObservableProperty] private string _value; public StudentSimpleGradeRow(Grade grade, Action save) { _grade = grade; _save = save; CategoryLabel = GradeCategoryDisplay.Label(grade.Category); DateDisplay = grade.Date.ToString("dd.MM.yyyy"); _value = grade.Value; } [RelayCommand] private void Save() { _grade.Value = Value.Trim(); _save(_grade); } }