Tagesflagge im Sitzplan, Leistungsüberblick mit Zielnoten-Rechner
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
- 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>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user