Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
T
adminandClaude Sonnet 5.5 651c9cddf8 Förderarbeit als dritte Folgearbeits-Art ergänzen
Neben Wiederholung und Nachschreibarbeit gibt es im Arbeitsdialog die Art
Förderarbeit (Exam.IsSupportWork). Sie darf am Tag der ersten Arbeit liegen,
übernimmt dessen Datum als Vorschlag und ersetzt wie eine Nachschreibarbeit
nur fehlende Noten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:55:27 +02:00

334 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
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 IStudentRepository? _students;
private readonly double _examMaxPoints;
private List<ExamResult> _gradedResults = [];
public string ExamTitle => _exam.Title;
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
public string NiveauLabel => _exam.Niveau.HasValue ? NiveauDisplay.ToName(_exam.Niveau) : "";
[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<GradeBarItem> GradeDistribution { get; } = [];
public ObservableCollection<TaskAnalysisItem> TaskAnalysis { get; } = [];
public ObservableCollection<RepeatComparisonItem> RepeatComparison { get; } = [];
public bool HasRepeatComparison => RepeatComparison.Count > 0;
public string RepeatComparisonTitle { get; private set; } = "";
/// "Wiederholung", "Nachschreibarbeit" bzw. "Förderarbeit" — Beschriftung der Folgearbeit im Vergleich.
public string FollowUpLabel { get; private set; } = "Wiederholung";
public string FollowUpComparisonHeading => $"Vergleich mit der {FollowUpLabel}";
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
public ExamEvaluationDialogViewModel(IExamRepository exams, IExamResultRepository results,
GradingService grading, Exam exam, GradingSystem gradingSystem,
IStudentRepository? students = null)
{
_exams = exams; _results = results; _grading = grading; _exam = exam;
_gradingSystem = gradingSystem; _students = students;
_examMaxPoints = ExamScoringService.Maximum(exam.Tasks, ExamVariant.A);
foreach (var e in exam.GradingKey.OrderByDescending(e => e.MinPercent))
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
LoadResults();
LoadRepeatComparison(students);
}
private void LoadRepeatComparison(IStudentRepository? students)
{
RepeatComparison.Clear();
RepeatComparisonTitle = "";
var original = _exam.RepeatOfExamId is { } originalId ? _exams.GetById(originalId) : _exam;
if (original is null) return;
var repeat = _exam.RepeatOfExamId is not null ? _exam
: _exams.GetByGroup(_exam.GroupId).Where(e => e.RepeatOfExamId == _exam.Id)
.OrderBy(e => e.Date).FirstOrDefault();
if (repeat is null) return;
FollowUpLabel = repeat.IsSupportWork ? "Förderarbeit" : repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung";
var rule = repeat.ReplacesMissingGrade() ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch
{
RepeatGradeRule.Repeat => "Wiederholung zählt",
RepeatGradeRule.Original => "Erste Arbeit zählt",
_ => "Bessere zählt",
};
RepeatComparisonTitle = $"Erste Arbeit: {original.Title} · {FollowUpLabel}: {repeat.Title} · {rule}";
var first = _results.GetByExam(original.Id).Where(r => !r.Absent && r.Grade is not null)
.ToDictionary(r => r.StudentId);
var second = _results.GetByExam(repeat.Id).Where(r => !r.Absent && r.Grade is not null)
.ToDictionary(r => r.StudentId);
var names = students?.GetByGroup(_exam.GroupId).ToDictionary(s => s.Id, s => s.FullName);
foreach (var studentId in first.Keys.Union(second.Keys)
.OrderBy(id => names?.GetValueOrDefault(id) ?? id.ToString()))
{
first.TryGetValue(studentId, out var originalResult);
second.TryGetValue(studentId, out var repeatResult);
var entries = new List<(Exam Exam, string Grade)>();
if (originalResult?.Grade is { } g1) entries.Add((original, g1));
if (repeatResult?.Grade is { } g2) entries.Add((repeat, g2));
var effective = ExamScoringService.EffectiveGrades(entries, _gradingSystem);
RepeatComparison.Add(new RepeatComparisonItem(
names?.GetValueOrDefault(studentId) ?? studentId.ToString(),
originalResult?.Grade ?? "–", repeatResult?.Grade ?? "–",
effective.FirstOrDefault() ?? "–"));
}
OnPropertyChanged(nameof(HasRepeatComparison));
OnPropertyChanged(nameof(RepeatComparisonTitle));
OnPropertyChanged(nameof(FollowUpLabel));
}
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
? "–"
: _exam.Tasks.Any(t => t.Variant is not null)
? $"A ab {e.MinPercent / 100.0 * _examMaxPoints:0.##} / " +
$"B ab {e.MinPercent / 100.0 * ExamScoringService.Maximum(_exam.Tasks, ExamVariant.B):0.##} Punkten"
: $"ab {(e.MinPercent / 100.0 * _examMaxPoints).ToString("0.##", CultureInfo.InvariantCulture)} " +
$"von {_examMaxPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten";
}
private List<GradingKeyEntry> 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);
var results = _results.GetByExam(_exam.Id);
foreach (var result in results.Where(r => !r.Absent))
result.Grade = _grading.CalculateGrade(result.TotalPoints,
ExamScoringService.Maximum(_exam.Tasks, result.Variant), key);
_results.SaveMany(results);
LoadRepeatComparison(_students);
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,
ExamScoringService.Maximum(_exam.Tasks, r.Variant), 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();
var labels = ExamScoringService.TaskLabels(tasks);
for (var i = 0; i < tasks.Count; i++)
{
var task = tasks[i];
var achieved = _gradedResults.Where(r => i < r.Points.Count &&
ExamScoringService.AppliesTo(task, r.Variant)).Select(r => r.Points[i]).ToList();
var avgPercent = task.MaxPoints <= 0 || achieved.Count == 0
? 0
: achieved.Average() / task.MaxPoints * 100.0;
var suffix = task.Variant is null ? "" : $" ({task.Variant})";
TaskAnalysis.Add(new TaskAnalysisItem(labels[i] + suffix, avgPercent));
}
}
private static double ComputeMedian(List<int> 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 csv = new CsvBuilder()
.AddRow("Klausur", _exam.Title)
.AddRow("Datum", _exam.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture))
.AddBlankRow()
.AddRow("Notenspiegel")
.AddRow("Note", "Anzahl", "Anteil");
foreach (var g in GradeDistribution)
csv.AddRow(g.Grade, g.Count, Percent(g.Count, GradedCount));
csv.AddBlankRow()
.AddRow("Durchschnitt", AverageDisplay)
.AddRow("Median", MedianDisplay);
if (!string.IsNullOrEmpty(BelowThresholdLabel1)) csv.AddRow(BelowThresholdLabel1, BelowThresholdDisplay1);
if (!string.IsNullOrEmpty(BelowThresholdLabel2)) csv.AddRow(BelowThresholdLabel2, BelowThresholdDisplay2);
csv.AddRow("Bewertet", GradedCount)
.AddRow("Abwesend", AbsentCount);
if (HasRepeatComparison)
{
csv.AddBlankRow().AddRow("Vergleich", RepeatComparisonTitle)
.AddRow("Schüler", "Erste Arbeit", FollowUpLabel, "Zählt");
foreach (var item in RepeatComparison)
csv.AddRow(item.StudentName, item.OriginalGrade, item.RepeatGrade, item.EffectiveGrade);
}
csv.AddBlankRow()
.AddRow("Aufgabenanalyse")
.AddRow("Aufgabe", "Ø Erfüllungsgrad", "Auffällig schwach");
foreach (var t in TaskAnalysis)
csv.AddRow(t.Label, t.AvgPercentDisplay, t.IsWeak ? "ja" : "");
return csv.ToString();
}
}
// ── Notenspiegel-Balken ────────────────────────────────────────────────────
public record RepeatComparisonItem(string StudentName, string OriginalGrade,
string RepeatGrade, string EffectiveGrade);
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(string label, double avgPercent)
{
Label = label;
AvgPercent = avgPercent;
AvgPercentDisplay = $"{avgPercent.ToString("0.#", CultureInfo.InvariantCulture)} %";
IsWeak = avgPercent < 50.0;
BarWidth = Math.Clamp(avgPercent, 0, 100) * 2.0;
}
}