Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs
adminandClaude Sonnet 5 1215d4ac22
CI / build-and-test (push) Canceled after 0s
Tagesflagge im Sitzplan, Leistungsüberblick mit Zielnoten-Rechner
- Mitarbeit: Tagesflagge (👑 Spitzentag / 😴 Schlaftag /  Schlechter Tag)
  je Schüler und Sitzung. Primär im Sitzplatz-Dialog (⇧1/2/3, ⇧X) mit
  Badge auf der Sitzplatz-Kachel; Fallback in der Schnelleingabe für
  Lerngruppen ohne Sitzplan.
- Noten: neuer Dialog "Überblick" in der Notenübersicht zeigt Klausuren,
  Mitarbeit- und sonstige Noten eines Schülers samt berechneter
  Zeugnisnote. Zielnoten-Rechner (ReportGradeTargetCalculator) beantwortet
  "was brauche ich noch für Note X", ein Was-wäre-wenn-Rechner simuliert
  eine zusätzliche Klausurnote. Bewerter-/Schülermodus per Umschalter im
  selben Fenster — Bewertermodus zeigt zusätzlich Kursdurchschnitt je
  Klausur und erlaubt das Bearbeiten von Mitarbeit-/Sonstige-Noten.
  Klausurverlauf als Balken-Sparkline wie die bestehende Notenentwicklung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:43:47 +02:00

311 lines
14 KiB
C#
Raw Permalink 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.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<ParticipationPeriodOption> 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<StudentExamRow> 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<StudentSimpleGradeRow> ParticipationRows { get; } = [];
public ObservableCollection<StudentSimpleGradeRow> 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<string>();
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<Grade> _save;
public string CategoryLabel { get; }
public string DateDisplay { get; }
[ObservableProperty] private string _value;
public StudentSimpleGradeRow(Grade grade, Action<Grade> 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);
}
}