diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs new file mode 100644 index 0000000..9d3e6a5 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs @@ -0,0 +1,258 @@ +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; +using System.Text; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Klausurauswertung (1.5) ─────────────────────────────────────────────────── + +public partial class ExamEvaluationDialogViewModel : ObservableObject +{ + private readonly IExamRepository _exams; + private readonly IExamResultRepository _results; + private readonly GradingService _grading; + private readonly Exam _exam; + private readonly GradingSystem _gradingSystem; + private readonly double _examMaxPoints; + private List _gradedResults = []; + + public string ExamTitle => _exam.Title; + public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy"); + + [ObservableProperty] private int _gradedCount; + [ObservableProperty] private int _absentCount; + [ObservableProperty] private string _averageDisplay = "–"; + [ObservableProperty] private string _medianDisplay = "–"; + [ObservableProperty] private string _belowThresholdLabel1 = ""; + [ObservableProperty] private string _belowThresholdDisplay1 = ""; + [ObservableProperty] private string _belowThresholdLabel2 = ""; + [ObservableProperty] private string _belowThresholdDisplay2 = ""; + [ObservableProperty] private string _gradingKeyValidation = ""; + [ObservableProperty] private string _gradingKeyStatus = ""; + + public ObservableCollection GradeDistribution { get; } = []; + public ObservableCollection TaskAnalysis { get; } = []; + public ObservableCollection GradingKeyEntries { get; } = []; + + public ExamEvaluationDialogViewModel(IExamRepository exams, IExamResultRepository results, + GradingService grading, Exam exam, GradingSystem gradingSystem) + { + _exams = exams; _results = results; _grading = grading; _exam = exam; + _gradingSystem = gradingSystem; + _examMaxPoints = exam.Tasks.Sum(t => t.MaxPoints); + + foreach (var e in exam.GradingKey.OrderByDescending(e => e.MinPercent)) + AddGradingKeyRowInternal(e.Grade, e.MinPercent); + + LoadResults(); + } + + private void LoadResults() + { + var all = _results.GetByExam(_exam.Id); + AbsentCount = all.Count(r => r.Absent); + _gradedResults = all.Where(r => !r.Absent).ToList(); + GradedCount = _gradedResults.Count; + Recompute(); + } + + // ── Notenschlüssel-Editor (1.5.3) ──────────────────────────────────────── + + [RelayCommand] + private void AddGradingKeyRow() => AddGradingKeyRowInternal("", 0); + + private void AddGradingKeyRowInternal(string grade, double minPercent) + { + var item = new GradingKeyEntryEditItem + { + Grade = grade, + MinPercent = minPercent, + OnChanged = OnGradingKeyRowChanged, + OnRemove = RemoveGradingKeyRow, + }; + GradingKeyEntries.Add(item); + RecomputeGradingKeyAbsolutes(); + } + + private void OnGradingKeyRowChanged() + { + RecomputeGradingKeyAbsolutes(); + Recompute(); + } + + private void RemoveGradingKeyRow(GradingKeyEntryEditItem item) + { + GradingKeyEntries.Remove(item); + RecomputeGradingKeyAbsolutes(); + Recompute(); + } + + private void RecomputeGradingKeyAbsolutes() + { + foreach (var e in GradingKeyEntries) + e.AbsolutePointsDisplay = _examMaxPoints <= 0 + ? "–" + : $"ab {(e.MinPercent / 100.0 * _examMaxPoints).ToString("0.##", CultureInfo.InvariantCulture)} " + + $"von {_examMaxPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten"; + } + + private List BuildCurrentGradingKey() => GradingKeyEntries + .Select(e => new GradingKeyEntry { Grade = e.Grade.Trim(), MinPercent = e.MinPercent }) + .OrderByDescending(e => e.MinPercent) + .ToList(); + + [RelayCommand] + private void ApplyGradingKey() + { + var key = BuildCurrentGradingKey(); + var error = _grading.ValidateGradingKey(key); + if (error is not null) { GradingKeyValidation = error; GradingKeyStatus = ""; return; } + GradingKeyValidation = ""; + _exam.GradingKey = key; + _exams.Save(_exam); + GradingKeyStatus = "Notenschlüssel für diese Klausur übernommen."; + } + + // ── Auswertung neu berechnen ────────────────────────────────────────────── + + private void Recompute() + { + var currentKey = BuildCurrentGradingKey(); + GradingKeyValidation = _grading.ValidateGradingKey(currentKey) ?? ""; + + var grades = _gradedResults + .Select(r => _grading.CalculateGrade(r.TotalPoints, _examMaxPoints, currentKey)) + .ToList(); + + GradeDistribution.Clear(); + 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 currentKey.OrderByDescending(e => e.MinPercent)) + { + counts.TryGetValue(entry.Grade, out var count); + GradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount)); + } + + AverageDisplay = grades.Count == 0 + ? "–" + : _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList()) + .ToString("0.00", CultureInfo.InvariantCulture); + + var numeric = grades.Select(g => int.TryParse(g, out var n) ? (int?)n : null) + .Where(n => n.HasValue).Select(n => n!.Value) + .OrderBy(n => n).ToList(); + MedianDisplay = numeric.Count == 0 ? "–" : ComputeMedian(numeric).ToString("0.##", CultureInfo.InvariantCulture); + + if (_gradingSystem == GradingSystem.Points0To15) + { + BelowThresholdLabel1 = "Anteil < 5 Punkte"; + BelowThresholdDisplay1 = Percent(numeric.Count(n => n < 5), numeric.Count); + BelowThresholdLabel2 = "Anteil < 4 Punkte"; + BelowThresholdDisplay2 = Percent(numeric.Count(n => n < 4), numeric.Count); + } + else + { + BelowThresholdLabel1 = "Anteil nicht ausreichend (Note 5/6)"; + BelowThresholdDisplay1 = Percent(numeric.Count(n => n >= 5), numeric.Count); + BelowThresholdLabel2 = ""; + BelowThresholdDisplay2 = ""; + } + + RecomputeTaskAnalysis(); + } + + private void RecomputeTaskAnalysis() + { + TaskAnalysis.Clear(); + var tasks = _exam.Tasks.OrderBy(t => t.Nr).ToList(); + for (var i = 0; i < tasks.Count; i++) + { + var task = tasks[i]; + var achieved = _gradedResults.Where(r => i < r.Points.Count).Select(r => r.Points[i]).ToList(); + var avgPercent = task.MaxPoints <= 0 || achieved.Count == 0 + ? 0 + : achieved.Average() / task.MaxPoints * 100.0; + TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, task.Title, avgPercent)); + } + } + + private static double ComputeMedian(List sorted) + { + var n = sorted.Count; + return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0; + } + + private static string Percent(int count, int total) => + total == 0 ? "–" : $"{(count * 100.0 / total).ToString("0.#", CultureInfo.InvariantCulture)} %"; + + // ── Export (1.5.4) ──────────────────────────────────────────────────────── + + public string ExportCsv() + { + var sb = new StringBuilder(); + sb.AppendLine($"Klausur;{_exam.Title}"); + sb.AppendLine($"Datum;{_exam.Date:dd.MM.yyyy}"); + sb.AppendLine(); + sb.AppendLine("Notenspiegel"); + sb.AppendLine("Note;Anzahl;Anteil"); + foreach (var g in GradeDistribution) + sb.AppendLine($"{g.Grade};{g.Count};{Percent(g.Count, GradedCount)}"); + sb.AppendLine(); + sb.AppendLine($"Durchschnitt;{AverageDisplay}"); + sb.AppendLine($"Median;{MedianDisplay}"); + if (!string.IsNullOrEmpty(BelowThresholdLabel1)) sb.AppendLine($"{BelowThresholdLabel1};{BelowThresholdDisplay1}"); + if (!string.IsNullOrEmpty(BelowThresholdLabel2)) sb.AppendLine($"{BelowThresholdLabel2};{BelowThresholdDisplay2}"); + sb.AppendLine($"Bewertet;{GradedCount}"); + sb.AppendLine($"Abwesend;{AbsentCount}"); + sb.AppendLine(); + sb.AppendLine("Aufgabenanalyse"); + sb.AppendLine("Aufgabe;Ø Erfüllungsgrad;Auffällig schwach"); + foreach (var t in TaskAnalysis) + sb.AppendLine($"{t.Label};{t.AvgPercentDisplay};{(t.IsWeak ? "ja" : "")}"); + return sb.ToString(); + } +} + +// ── Notenspiegel-Balken ──────────────────────────────────────────────────── + +public class GradeBarItem +{ + public string Grade { get; } + public int Count { get; } + public string CountDisplay { get; } + public double BarWidth { get; } + + public GradeBarItem(string grade, int count, int totalGraded, int maxCount) + { + Grade = grade; + Count = count; + var percent = totalGraded == 0 ? 0 : count * 100.0 / totalGraded; + CountDisplay = $"{count} ({percent.ToString("0.#", CultureInfo.InvariantCulture)} %)"; + BarWidth = maxCount <= 0 ? 0 : count * 200.0 / maxCount; + } +} + +// ── Aufgabenanalyse-Zeile ────────────────────────────────────────────────── + +public class TaskAnalysisItem +{ + public string Label { get; } + public double AvgPercent { get; } + public string AvgPercentDisplay { get; } + public bool IsWeak { get; } + public double BarWidth { get; } + + public TaskAnalysisItem(int nr, string? title, double avgPercent) + { + Label = string.IsNullOrWhiteSpace(title) ? $"Aufgabe {nr}" : $"{nr}. {title}"; + AvgPercent = avgPercent; + AvgPercentDisplay = $"{avgPercent.ToString("0.#", CultureInfo.InvariantCulture)} %"; + IsWeak = avgPercent < 50.0; + BarWidth = Math.Clamp(avgPercent, 0, 100) * 2.0; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 187635d..f9df221 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -177,6 +177,7 @@ public partial class GroupDetailViewModel : ObservableObject public Func>? OnDuplicateExam { get; set; } public Func>? OnConfirmDeleteExam { get; set; } public Func? OnGradeExam { get; set; } + public Func? OnEvaluateExam { get; set; } public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades, @@ -286,6 +287,15 @@ public partial class GroupDetailViewModel : ObservableObject await OnGradeExam(exam); } + [RelayCommand(CanExecute = nameof(HasSelectedExam))] + private async Task EvaluateExam() + { + if (SelectedExam is null || OnEvaluateExam is null) return; + var exam = _exams.GetById(SelectedExam.Id); + if (exam is null) return; + await OnEvaluateExam(exam); + } + [RelayCommand(CanExecute = nameof(HasSelectedExam))] private async Task DuplicateExam() { @@ -341,6 +351,7 @@ public partial class GroupDetailViewModel : ObservableObject { EditExamCommand.NotifyCanExecuteChanged(); GradeExamCommand.NotifyCanExecuteChanged(); + EvaluateExamCommand.NotifyCanExecuteChanged(); DuplicateExamCommand.NotifyCanExecuteChanged(); DeleteExamCommand.NotifyCanExecuteChanged(); AdvanceExamStatusCommand.NotifyCanExecuteChanged(); diff --git a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml new file mode 100644 index 0000000..9770e17 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +