Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
T
adminandClaude Sonnet 5 482942ce09 Niveaudifferenzierung (E/G/Förder) für Klassen und Kurse
Für Fächer mit Binnendifferenzierung (z.B. Mathematik E/G-Niveau,
Förderniveau) innerhalb derselben Lerngruppe:

- LearningGroup.IsDifferentiated: neue Checkbox in den Stammdaten,
  aktiviert die Niveau-Funktionen optional für Klassen und Kurse.
- Enrollment.Niveau: Niveau-Zuordnung je Schüler innerhalb der Gruppe,
  editierbar über eine neue Spalte im Schüler-Tab (nur sichtbar bei
  differenzierter Gruppe).
- Exam.Niveau: Klausuren können optional einem Niveau zugeordnet
  werden. Workflow nutzt die bestehende Duplizieren-Funktion (1.1.4):
  G-Klausur anlegen, für die E-Variante duplizieren und eigene
  Aufgaben/Punkte vergeben — Niveau wird beim Duplizieren bewusst
  zurückgesetzt, damit nicht versehentlich zwei gleiche entstehen.
- Punkteeingabe und Auswertung filtern automatisch auf die Schüler
  mit passendem Niveau; Klausuren ohne Niveau-Zuordnung verhalten
  sich unverändert (gesamte Gruppe). Beide Dialoge zeigen das Niveau
  als Badge im Titel, die Klausurenliste als eigene Spalte.
- Individuelle Förderklausuren (nur für einen Schüler) laufen
  pragmatisch über die bestehende Abwesend-Markierung der übrigen
  Schüler statt über eine eigene Pro-Schüler-Zuordnung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 02:24:26 +02:00

260 lines
11 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 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<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<GradingKeyEntryEditItem> 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<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);
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<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 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;
}
}