diff --git a/LehrerApp.Core/Models/Participation.cs b/LehrerApp.Core/Models/Participation.cs index af32d6a..45be265 100644 --- a/LehrerApp.Core/Models/Participation.cs +++ b/LehrerApp.Core/Models/Participation.cs @@ -32,9 +32,18 @@ public class ParticipationEntry // ParticipationCountSuggestion. public int RaisedHandCount { get; set; } public int CalledOnCount { get; set; } + /// + /// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders + /// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche + /// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung + /// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation. + /// + public DayHighlightKind? DayHighlight { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } +public enum DayHighlightKind { Standout, Sleepy, Rough } + /// /// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine /// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate diff --git a/LehrerApp.Core/Services/ReportGradeTargetCalculator.cs b/LehrerApp.Core/Services/ReportGradeTargetCalculator.cs new file mode 100644 index 0000000..ecce664 --- /dev/null +++ b/LehrerApp.Core/Services/ReportGradeTargetCalculator.cs @@ -0,0 +1,48 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der +/// gesuchte/unbekannte ist. +public enum GradeBucketKind { Exams, Participation, Other } + +public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable); + +/// +/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?" +/// (Nutzer-Feedback) — kehrt um: löst nach +/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten +/// der übrigen Bereiche (gewichtet nach ) genau die Zielnote ergibt. +/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen +/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie +/// bei fehlenden Bereichen. +/// +public static class ReportGradeTargetCalculator +{ + public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor, + double? examsAverage, double? participationAverage, double? otherAverage, + GradingScheme scheme, GradingSystem system) + { + var solvePercent = solveFor switch + { + GradeBucketKind.Exams => scheme.ExamsPercent, + GradeBucketKind.Participation => scheme.ParticipationPercent, + _ => scheme.OtherPercent, + }; + // Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein — + // keine Zielnoten-Frage für ihn beantwortbar. + if (solvePercent <= 0) return null; + + var known = new List<(double Avg, double Percent)>(); + if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent)); + if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent)); + if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent)); + + var totalPercent = known.Sum(k => k.Percent) + solvePercent; + var knownContribution = known.Sum(k => k.Avg * k.Percent); + var required = (target * totalPercent - knownContribution) / solvePercent; + + var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0); + return new TargetGradeResult(required, required >= min && required <= max); + } +} diff --git a/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs b/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs index fca4c2d..12683d0 100644 --- a/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs @@ -103,6 +103,47 @@ public sealed class QuickInputViewModelTests Assert.Null(rows[0].GetRating("quality")); } + [Fact] + public void SetDayHighlight_SpeichertAufDerZeileUndAktualisiertDieAnzeige() + { + var aspects = new List + { + new(new ParticipationAspect { Key = "quality", Label = "Qualität" }), + }; + var rows = new List + { + new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []), + }; + var vm = new QuickInputViewModel(rows, aspects); + + vm.SetDayHighlight(DayHighlightKind.Standout); + + Assert.Equal(DayHighlightKind.Standout, rows[0].DayHighlight); + Assert.Equal("👑", vm.CurrentStudentDayHighlightSymbol); + Assert.Equal("Spitzentag", vm.CurrentStudentDayHighlightLabel); + } + + [Fact] + public void ZurueckZuVorherigemSchueler_ZeigtDessenTagesflaggeWiederAn() + { + var aspects = new List + { + new(new ParticipationAspect { Key = "quality", Label = "Qualität" }), + }; + var rows = new List + { + new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []), + new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []), + }; + var vm = new QuickInputViewModel(rows, aspects); + + vm.SetDayHighlight(DayHighlightKind.Sleepy); // Anna + vm.NextStudent(); // zu Ben, keine Flagge + vm.PreviousStudent(); // zurück zu Anna + + Assert.Equal(DayHighlightKind.Sleepy, vm.CurrentStudentDayHighlight); + } + [Fact] public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf() { diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs index 46c06f7..2f748be 100644 --- a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs @@ -472,6 +472,7 @@ public sealed class SeatingPlanViewModelTests vm.SetRatingByNumber(5); vm.ApplyAttendanceShortcut(1, clear: false); vm.ApplyHomeworkShortcut(7, clear: false); + vm.ApplyDayHighlightShortcut(1, clear: false); var session = Assert.Single(sessions.GetByGroup(groupId)); Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date); @@ -481,6 +482,27 @@ public sealed class SeatingPlanViewModelTests Assert.Equal(AttendanceStatus.Present, entry.Attendance); Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework); Assert.True(entry.HomeworkMissing); + Assert.Equal(DayHighlightKind.Standout, entry.DayHighlight); + } + + [Fact] + public void SitzplatzBewertung_TagesflaggeKannWiederGeloeschtWerden() + { + var groupId = Guid.NewGuid(); + var studentId = Guid.NewGuid(); + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(), + groupId, studentId, "Beispiel, Anna", canEdit: true); + + vm.ApplyDayHighlightShortcut(3, clear: false); // aus Versehen "Schlechter Tag" gesetzt + vm.ApplyDayHighlightShortcut(null, clear: true); + + var session = Assert.Single(sessions.GetByGroup(groupId)); + var entry = entries.GetBySessionAndStudent(session.Id, studentId)!; + Assert.Null(entry.DayHighlight); + Assert.Equal("Keine Markierung", vm.DayHighlightLabel); + Assert.All(vm.DayHighlightChoices, c => Assert.Equal(c.Kind is null, c.IsSelected)); } [Fact] diff --git a/LehrerApp.Desktop.Tests/StudentPerformanceOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/StudentPerformanceOverviewViewModelTests.cs new file mode 100644 index 0000000..b2b6328 --- /dev/null +++ b/LehrerApp.Desktop.Tests/StudentPerformanceOverviewViewModelTests.cs @@ -0,0 +1,146 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für den Schüler-Leistungsüberblick (Nutzer-Feedback): Klausuren/Mitarbeit/Sonstige +/// im Überblick, berechnete Zeugnisnote, Zielnoten- und Was-wäre-wenn-Rechner sowie der +/// Bewerter-/Schülermodus-Unterschied (Kursdurchschnitt, Bearbeitbarkeit). +public sealed class StudentPerformanceOverviewViewModelTests +{ + private static readonly Guid GroupId = Guid.NewGuid(); + private static readonly Guid StudentId = Guid.NewGuid(); + private static readonly Guid OtherStudentId = Guid.NewGuid(); + + private static StudentPerformanceOverviewViewModel BuildViewModel( + FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null, + FakeSchemes? schemes = null, GradingSystem system = GradingSystem.Grades1To6) + { + var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }; + var schemesRepo = schemes ?? new FakeSchemes(); + if (schemes is null) schemesRepo.SetForGroup(GroupId, scheme); + + return new StudentPerformanceOverviewViewModel( + grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(), + new FakeMemberships([]), schemesRepo, new GradingService(), + GroupId, StudentId, GroupType.Class, system, "Beispiel, Anna", "Testkurs", "2025/26"); + } + + [Fact] + public void ExamRows_ZeigtEigeneNoteUndKursdurchschnitt() + { + var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) }; + var exams = new FakeExams([exam]); + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" }); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = OtherStudentId, Grade = "4" }); + + var vm = BuildViewModel(exams, results); + + var row = Assert.Single(vm.ExamRows); + Assert.Equal("2", row.OwnGradeDisplay); + Assert.Equal("3.0", row.ClassAverageDisplay); + } + + [Fact] + public void ExamHistory_ZeigtEigenenVerlaufUndMarkiertNotenabfall() + { + var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) }; + var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K2", Date = new DateOnly(2025, 9, 24) }; + var exams = new FakeExams([exam1, exam2]); + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam1.Id, StudentId = StudentId, Grade = "2" }); + results.Add(new ExamResult { ExamId = exam2.Id, StudentId = StudentId, Grade = "5" }); // Abfall + mangelhaft + + var vm = BuildViewModel(exams, results); + + Assert.Equal(2, vm.ExamHistory.Points.Count); + Assert.False(vm.ExamHistory.Points[0].IsWarning); + Assert.True(vm.ExamHistory.Points[1].IsWarning); + Assert.Contains("Abfall", vm.ExamHistory.Points[1].WarningText); + Assert.Contains("Versetzungsgefährdung", vm.ExamHistory.Points[1].WarningText); + Assert.True(vm.ExamHistory.Points[1].BarHeight < vm.ExamHistory.Points[0].BarHeight); + } + + [Fact] + public void AktuelleZeugnisnote_KombiniertAlleDreiBereiche() + { + var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) }; + var exams = new FakeExams([exam]); + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" }); + var grades = new FakeGrades(); + grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation, + Date = new DateOnly(2025, 9, 5), Value = "3" }); + grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other, + Date = new DateOnly(2025, 9, 5), Value = "1" }); + + var vm = BuildViewModel(exams, results, grades); + + // (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2 + Assert.Equal("Note 2", vm.CurrentReportGradeDisplay); + } + + [Fact] + public void Zielnote_BerechnetNoetigenKlausurschnitt() + { + var grades = new FakeGrades(); + grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation, + Date = new DateOnly(2025, 9, 5), Value = "3" }); + grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other, + Date = new DateOnly(2025, 9, 5), Value = "2" }); + var vm = BuildViewModel(grades: grades); + + vm.TargetGradeText = "2"; + vm.TargetBucketName = "Klausuren"; + + // Ziel 2,0 = (x*50 + 3*40 + 2*10)/100 => 200 = 50x+120+20 => x = 1,2 + Assert.Contains("1.2", vm.TargetResultDisplay); + } + + [Fact] + public void WasWaereWenn_ZeigtZeugnisnoteMitZusaetzlicherKlausurAn() + { + var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) }; + var exams = new FakeExams([exam]); + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" }); + var grades = new FakeGrades(); + grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation, + Date = new DateOnly(2025, 9, 5), Value = "2" }); + + var vm = BuildViewModel(exams, results, grades); + + vm.WhatIfExamGradeText = "6"; + + // Klausuren neu: (2+6)/2=4 (50%), Mitarbeit 2 (40%, Sonstige fehlt) -> (4*50+2*40)/90 = 3,11 -> kaufm. 3 + Assert.Contains("3", vm.WhatIfResultDisplay); + } + + [Fact] + public void SaveCommand_AufMitarbeitszeile_SpeichertUndAktualisiertDurchschnitt() + { + var grades = new FakeGrades(); + var grade = new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation, + Date = new DateOnly(2025, 9, 5), Value = "3" }; + grades.Add(grade); + var vm = BuildViewModel(grades: grades); + + var row = Assert.Single(vm.ParticipationRows); + row.Value = "1"; + row.SaveCommand.Execute(null); + + Assert.Equal("1.0", vm.ParticipationAverageDisplay); + Assert.Equal("1", grades.GetByStudentAndGroup(StudentId, GroupId).Single().Value); + } + + [Fact] + public void OhneWerteImZeitraum_ZeigtHinweisStattZeugnisnote() + { + var vm = BuildViewModel(); + + Assert.Contains("Noch nicht berechenbar", vm.CurrentReportGradeDisplay); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs index 793b85a..29cc665 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs @@ -54,6 +54,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject public Func? OnManageStudentGrades { get; set; } public Func? OnCollectiveGrade { get; set; } public Func? OnReportGrades { get; set; } + public Func? OnShowPerformanceOverview { get; set; } public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams, IExamResultRepository results, IStudentRepository students, @@ -107,6 +108,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject Recompute(); } + [RelayCommand] + private async Task ShowPerformanceOverview() + { + if (SelectedRow is null || OnShowPerformanceOverview is null) return; + await OnShowPerformanceOverview(SelectedRow); + Recompute(); + } + [RelayCommand] private async Task CollectiveGrade() { diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index 8f01c2c..c3eacf0 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -158,6 +158,7 @@ public partial class ParticipationTabViewModel : ObservableObject row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val); row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val); row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val); + row.DayHighlightChangedCallback = (sid, val) => SaveDayHighlight(sessionId, sid, val); StudentRows.Add(row); } QuickInputCommand.NotifyCanExecuteChanged(); @@ -212,6 +213,15 @@ public partial class ParticipationTabViewModel : ObservableObject _entries.Save(entry); } + private void SaveDayHighlight(Guid sessionId, Guid studentId, DayHighlightKind? value) + { + if (IsReadOnly) return; + var entry = _entries.GetBySessionAndStudent(sessionId, studentId) + ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; + entry.DayHighlight = value; + _entries.Save(entry); + } + [RelayCommand] private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible; @@ -389,6 +399,7 @@ public partial class ParticipationStudentRow : ObservableObject [ObservableProperty] private HomeworkStatus? _homework; [ObservableProperty] private AttendanceStatus? _attendance; + [ObservableProperty] private DayHighlightKind? _dayHighlight; public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance); public string AttendanceTooltip => AttendanceDisplay.Label(Attendance); @@ -405,6 +416,7 @@ public partial class ParticipationStudentRow : ObservableObject public Action? OnCompetencyRatingChanged { get; set; } public Action? HomeworkChangedCallback { get; set; } public Action? AttendanceChangedCallback { get; set; } + public Action? DayHighlightChangedCallback { get; set; } public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry, List aspects, List competencyCodes) @@ -414,6 +426,7 @@ public partial class ParticipationStudentRow : ObservableObject _aspectDefs = aspects; _homework = HomeworkDisplay.Effective(entry); _attendance = entry.Attendance; + _dayHighlight = entry.DayHighlight; foreach (var a in aspects) { @@ -463,6 +476,12 @@ public partial class ParticipationStudentRow : ObservableObject HomeworkChangedCallback?.Invoke(StudentId, value); } + public void SetDayHighlight(DayHighlightKind? value) + { + DayHighlight = value; + DayHighlightChangedCallback?.Invoke(StudentId, value); + } + [RelayCommand] private void CycleAttendance() { @@ -614,6 +633,27 @@ public static class AttendanceDisplay }; } +// ── Tagesflagge (Nutzer-Feedback) ────────────────────────────────────────────── + +public static class DayHighlightDisplay +{ + public static string Symbol(DayHighlightKind? kind) => kind switch + { + DayHighlightKind.Standout => "👑", + DayHighlightKind.Sleepy => "😴", + DayHighlightKind.Rough => "⚡", + _ => "", + }; + + public static string Label(DayHighlightKind? kind) => kind switch + { + DayHighlightKind.Standout => "Spitzentag", + DayHighlightKind.Sleepy => "Schlaftag", + DayHighlightKind.Rough => "Schlechter Tag", + _ => "Keine Markierung", + }; +} + // ── Eine Bewertungszelle ────────────────────────────────────────────────────── public partial class RatingCell : ObservableObject @@ -800,12 +840,31 @@ public partial class QuickInputViewModel : ObservableObject [ObservableProperty] private string _progressText = ""; [ObservableProperty] private bool _currentStudentIsAbsent; [ObservableProperty] private string _currentStudentAttendanceLabel = ""; + [ObservableProperty] private DayHighlightKind? _currentStudentDayHighlight; /// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der /// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein /// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt. public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0; + /// Tagesflagge (Nutzer-Feedback): Fallback für Kurse ohne Sitzplan — im Sitzplatz-Dialog gibt + /// es dieselbe Auswahl bereits über SeatAssessmentViewModel.DayHighlightChoices. + public string CurrentStudentDayHighlightSymbol => DayHighlightDisplay.Symbol(CurrentStudentDayHighlight); + public string CurrentStudentDayHighlightLabel => DayHighlightDisplay.Label(CurrentStudentDayHighlight); + + public void SetDayHighlight(DayHighlightKind? value) + { + if (_rows.Count == 0) return; + _rows[StudentIndex].SetDayHighlight(value); + CurrentStudentDayHighlight = value; + } + + partial void OnCurrentStudentDayHighlightChanged(DayHighlightKind? value) + { + OnPropertyChanged(nameof(CurrentStudentDayHighlightSymbol)); + OnPropertyChanged(nameof(CurrentStudentDayHighlightLabel)); + } + public ObservableCollection AspectRows { get; } = []; /// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere @@ -824,7 +883,8 @@ public partial class QuickInputViewModel : ObservableObject _ => "1–5 bewerten", }; return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " + - "Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · Esc schließen"; + "Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · " + + "⇧1/2/3 Tagesflagge, ⇧0 löschen · Esc schließen"; } } @@ -852,6 +912,7 @@ public partial class QuickInputViewModel : ObservableObject ProgressText = $"{index + 1} / {_rows.Count}"; CurrentStudentIsAbsent = row.IsAbsent; CurrentStudentAttendanceLabel = row.AttendanceTooltip; + CurrentStudentDayHighlight = row.DayHighlight; AspectRows.Clear(); foreach (var (a, i) in _aspects.Select((a, i) => (a, i))) diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index 7d21db3..e89ccc3 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -547,11 +547,13 @@ public partial class SeatCellViewModel : ObservableObject [ObservableProperty] private double _lessonOpacity = 1.0; [ObservableProperty] private string _attendanceBadge = ""; [ObservableProperty] private string _homeworkBadge = ""; + [ObservableProperty] private string _dayHighlightBadge = ""; [ObservableProperty] private int _raisedHandCount; [ObservableProperty] private int _calledOnCount; public ObservableCollection SituationTags { get; } = []; public bool HasAttendanceBadge => AttendanceBadge.Length > 0; public bool HasHomeworkBadge => HomeworkBadge.Length > 0; + public bool HasDayHighlightBadge => DayHighlightBadge.Length > 0; public bool ShowLessonOverview => IsOccupied && !CanEdit; [ObservableProperty] private bool _canRecordLesson; public bool IsOccupied => SelectedOption.StudentId.HasValue; @@ -644,6 +646,7 @@ public partial class SeatCellViewModel : ObservableObject var attendance = entry?.Attendance; AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance); HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry)); + DayHighlightBadge = DayHighlightDisplay.Symbol(entry?.DayHighlight); RaisedHandCount = entry?.RaisedHandCount ?? 0; CalledOnCount = entry?.CalledOnCount ?? 0; LessonOpacity = attendance is not null and not AttendanceStatus.Present @@ -652,6 +655,7 @@ public partial class SeatCellViewModel : ObservableObject foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text); OnPropertyChanged(nameof(HasAttendanceBadge)); OnPropertyChanged(nameof(HasHomeworkBadge)); + OnPropertyChanged(nameof(HasDayHighlightBadge)); } } @@ -677,6 +681,7 @@ public partial class SeatAssessmentViewModel : ObservableObject [ObservableProperty] private int _selectedAspectIndex; [ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert"; [ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben"; + [ObservableProperty] private string _dayHighlightLabel = "Keine Markierung"; public string StudentName { get; } public string SessionDisplay { get; } @@ -687,6 +692,7 @@ public partial class SeatAssessmentViewModel : ObservableObject public ObservableCollection AspectRows { get; } = []; public ObservableCollection AttendanceChoices { get; } = []; public ObservableCollection HomeworkChoices { get; } = []; + public ObservableCollection DayHighlightChoices { get; } = []; public SeatAssessmentViewModel(IParticipationSessionRepository sessions, IParticipationRepository entries, IParticipationAspectRepository aspects, @@ -729,6 +735,7 @@ public partial class SeatAssessmentViewModel : ObservableObject BuildAttendanceChoices(); BuildHomeworkChoices(); + BuildDayHighlightChoices(); RefreshStatusChoices(); SelectAspect(0); } @@ -761,6 +768,17 @@ public partial class SeatAssessmentViewModel : ObservableObject HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework)); } + private void BuildDayHighlightChoices() + { + DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Standout), + DayHighlightDisplay.Label(DayHighlightKind.Standout), "⇧1", DayHighlightKind.Standout, SetDayHighlight)); + DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Sleepy), + DayHighlightDisplay.Label(DayHighlightKind.Sleepy), "⇧2", DayHighlightKind.Sleepy, SetDayHighlight)); + DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Rough), + DayHighlightDisplay.Label(DayHighlightKind.Rough), "⇧3", DayHighlightKind.Rough, SetDayHighlight)); + DayHighlightChoices.Add(new("·", "Keine Markierung", "⇧X", null, SetDayHighlight)); + } + public void SelectAspect(int index) { if (index < 0 || index >= AspectRows.Count) return; @@ -827,6 +845,17 @@ public partial class SeatAssessmentViewModel : ObservableObject if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status); } + public void ApplyDayHighlightShortcut(int? digit, bool clear) + { + if (!CanEdit) return; + var kind = clear ? null : digit switch + { + 1 => DayHighlightKind.Standout, 2 => DayHighlightKind.Sleepy, 3 => DayHighlightKind.Rough, + _ => (DayHighlightKind?)null, + }; + if (clear || digit is 1 or 2 or 3) SetDayHighlight(kind); + } + private void ApplyRating(string key, int? value) { if (!CanEdit || _entry is null) return; @@ -857,13 +886,23 @@ public partial class SeatAssessmentViewModel : ObservableObject RefreshStatusChoices(); } + private void SetDayHighlight(DayHighlightKind? kind) + { + if (!CanEdit || _entry is null) return; + _entry.DayHighlight = kind; + _entries.Save(_entry); + RefreshStatusChoices(); + } + private void RefreshStatusChoices() { AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance); HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry)); + DayHighlightLabel = DayHighlightDisplay.Label(_entry?.DayHighlight); foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance; var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry); foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework; + foreach (var choice in DayHighlightChoices) choice.IsSelected = choice.Kind == _entry?.DayHighlight; } } @@ -966,6 +1005,17 @@ public partial class SeatHomeworkChoice(string symbol, string label, string shor [RelayCommand] private void Apply() => apply(Status); } +public partial class SeatDayHighlightChoice(string symbol, string label, string shortcut, + DayHighlightKind? kind, Action apply) : ObservableObject +{ + public string Symbol { get; } = symbol; + public string Label { get; } = label; + public string Shortcut { get; } = shortcut; + public DayHighlightKind? Kind { get; } = kind; + [ObservableProperty] private bool _isSelected; + [RelayCommand] private void Apply() => apply(Kind); +} + public partial class SeatingPlanDialogViewModel : ObservableObject { private readonly ISeatingPlanRepository _plans; diff --git a/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs new file mode 100644 index 0000000..907a9a1 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs @@ -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 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 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 ParticipationRows { get; } = []; + public ObservableCollection 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(); + 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 _save; + + public string CategoryLabel { get; } + public string DateDisplay { get; } + [ObservableProperty] private string _value; + + public StudentSimpleGradeRow(Grade grade, Action 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); + } +} diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml index 1541205..2366e7a 100644 --- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml @@ -19,6 +19,9 @@ + + + + + Text="Mitarbeit: Q/W/E/R/T Aspekt · 1–5 Bewertung · +/− anpassen · Backspace löschen · ←/→ Aspekt | Anwesenheit: Strg+1/2/5/7/9/0, Strg+X | Hausaufgaben: ⌥+1/3/4/5/7/8/0, ⌥+X | Tagesflagge: ⇧1/2/3, ⇧X | Esc schließen"/> diff --git a/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml.cs index 74b0ba7..3218354 100644 --- a/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml.cs @@ -36,6 +36,12 @@ public partial class SeatAssessmentDialog : Window e.Handled = clear || digit is not null; return; } + if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + if (clear || digit is not null) vm.ApplyDayHighlightShortcut(digit, clear); + e.Handled = clear || digit is not null; + return; + } if (digit is not null) { vm.SetRatingByNumber(digit.Value); diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml index af3db2f..c323265 100644 --- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml @@ -154,6 +154,9 @@ + diff --git a/LehrerApp.Desktop/Views/Groups/StudentPerformanceOverviewDialog.axaml b/LehrerApp.Desktop/Views/Groups/StudentPerformanceOverviewDialog.axaml new file mode 100644 index 0000000..1ad3cf6 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/StudentPerformanceOverviewDialog.axaml @@ -0,0 +1,184 @@ + + + + + + + + + +