From 3f484ce3c8c55469111950a7e9b9d60365f467ed Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 27 Aug 2026 13:57:09 +0200 Subject: [PATCH] =?UTF-8?q?Klassenlehrer-Tab:=20vier=20Feature-Ideen=20umg?= =?UTF-8?q?esetzt=20(ohne=20Elterngespr=C3=A4chs-PDF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend. ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias $parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) — stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als PDF bewusst zurückgestellt, Konzept noch nicht geschärft. Co-Authored-By: Claude Sonnet 5 --- .../ClassTeacherViewModelsTests.cs | 143 ++++++++++++ .../ClassTeacherDetailsViewModel.cs | 34 ++- .../ClassTeacherOverviewViewModel.cs | 217 +++++++++++++++++- .../ClassTeacherOverviewView.axaml | 66 +++++- .../ClassTeacherRegisterView.axaml | 34 ++- TODO.md | 50 ++++ 6 files changed, 537 insertions(+), 7 deletions(-) diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 37433d7..2a68443 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -264,6 +264,149 @@ public sealed class ClassTeacherViewModelsTests Assert.Equal(row.StatusText, row.StatusTextWithGlyph); } + // ── Offene Entschuldigungen (Feature-Idee 2) ───────────────────────────── + + [Fact] + public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte() + { + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90, + ["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45, + ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + }; + + var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26)); + + // StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname", + // hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow). + Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName)); + Assert.Equal(6, rows[0].DaysOpen); + Assert.True(rows[0].IsOverdue); + Assert.Equal(1, rows[1].DaysOpen); + Assert.False(rows[1].IsOverdue); + } + + // ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ────────────────── + + [Fact] + public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler() + { + var entries = new[] + { + new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test", + "Hausaufgaben fehlen", "Negativ", "HA fehlt"), + new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test", + "Hausaufgaben fehlen", "Negativ", "HA fehlt"), + new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test", + "Hausaufgaben fehlen", "Negativ", "HA fehlt"), + new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test", + "Lob", "Positiv", "Gute Mitarbeit"), + }; + + var rows = ClassTeacherCategoryAggregateRow.Build(entries); + + Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName)); + var homework = rows[0]; + Assert.Equal(3, homework.Count); + Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel); + Assert.Contains("1× Ada Müller", homework.TopStudentsLabel); + } + + // ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ────── + + [Fact] + public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag() + { + // 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage. + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + }; + var names = new Dictionary { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" }; + + var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns( + absences, names, new HashSet()); + + var notice = Assert.Single(notices); + Assert.Equal("Mona Muster", notice.StudentName); + Assert.Contains("Montag", notice.Message); + Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind); + } + + [Fact] + public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe() + { + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + }; + + var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns( + absences, new Dictionary(), new HashSet()); + + Assert.Empty(notices); + } + + [Fact] + public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus() + { + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + }; + var names = new Dictionary { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" }; + + var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns( + absences, names, new HashSet { "Mona Muster" }); + + Assert.Empty(notices); + } + + // ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ───────────── + + [Fact] + public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName() + { + // Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde + // UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent + // muss deshalb FirstName/LastName getrennt verwenden, nicht FullName. + var students = new List + { + new() { FirstName = "Ada", LastName = "Müller" }, + new() { FirstName = "Ben", LastName = "Schmidt" }, + }; + + var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students); + + Assert.NotNull(match); + Assert.Equal("Ada", match!.FirstName); + } + + [Fact] + public void MatchStudent_LiefertNullOhneTreffer() + { + var students = new List { new() { FirstName = "Ada", LastName = "Müller" } }; + + Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students)); + } + private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index 25c9c73..6dd76d5 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -26,6 +26,33 @@ public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject } } +/// Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am +/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig, +/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben +/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen +/// berechnet, kein zusätzlicher WebUntis-Abruf. +public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel) +{ + public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel) + ? $"{Count}×" : $"{Count}× — {TopStudentsLabel}"; + + public static IReadOnlyList Build( + IReadOnlyList entries, int topStudentsPerCategory = 2) => + entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName)) + .GroupBy(e => e.CategoryName!) + .Select(g => + { + var topStudents = g.GroupBy(e => e.StudentDisplayName) + .Select(sg => (Name: sg.Key, Count: sg.Count())) + .OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name) + .Take(topStudentsPerCategory) + .Select(sg => $"{sg.Count}× {sg.Name}"); + return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents)); + }) + .OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName) + .ToList(); +} + /// Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst - /// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere /// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt @@ -107,6 +134,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public ObservableCollection Entries { get; } = []; public ObservableCollection AbsenceEntries { get; } = []; + public ObservableCollection CategoryAggregates { get; } = []; [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6); [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; @@ -118,6 +146,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public bool HasEntries => Entries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0; + public bool HasCategoryAggregates => CategoryAggregates.Count > 0; public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter) ? "Alle Schüler*innen" : StudentFilter; @@ -132,6 +161,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject StudentFilter = ""; Entries.Clear(); AbsenceEntries.Clear(); + CategoryAggregates.Clear(); Status = "Zeitraum wählen und laden."; NotifyListState(); } @@ -170,7 +200,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; } - Busy = true; Entries.Clear(); AbsenceEntries.Clear(); NotifyListState(); + Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState(); try { var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh); @@ -187,6 +217,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName, entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text)); } + foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row); var absences = absencesTask.Result.Where(MatchesStudentFilter); foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) @@ -216,5 +247,6 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject { OnPropertyChanged(nameof(HasEntries)); OnPropertyChanged(nameof(HasAbsenceEntries)); + OnPropertyChanged(nameof(HasCategoryAggregates)); } } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 0e534dd..dc6db34 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; @@ -163,11 +164,40 @@ public sealed record ClassTeacherPatternNotice(string StudentName, string Messag public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info; } +/// Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird — +/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend, +/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin +/// für die Jahresfehlquote geladenen Daten (), +/// kein zusätzlicher Abruf. +public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen) +{ + /// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen" + /// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische + /// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche + /// Regelung kann je nach Bundesland/Schulordnung abweichen. + public const int DeadlineDays = 3; + public string DateLabel => Date.ToString("dd.MM."); + public bool IsOverdue => DaysOpen > DeadlineDays; + public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt"; + + public static IReadOnlyList Build( + IReadOnlyList absenceDays, DateOnly today) => + absenceDays.Where(a => a.IsUnexcused) + .Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date, + today.DayNumber - a.Date.DayNumber)) + .OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName) + .ToList(); +} + public partial class ClassTeacherOverviewViewModel : ObservableObject { private readonly WebUntisSettingsService _settings; private readonly UntisReportCacheService _cache; private readonly SchoolYearService _schoolYear; + private readonly IWorkTaskRepository _workTasks; + private readonly IStudentRepository _students; + private readonly IParticipationRepository _participation; + private readonly IParticipationSessionRepository _participationSessions; public ClassTeacherDetailsViewModel DetailsTab { get; } public ObservableCollection Roster { get; } = []; @@ -175,6 +205,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public ObservableCollection SecondaryRoster { get; } = []; public ObservableCollection TrendDays { get; } = []; public ObservableCollection PatternNotices { get; } = []; + public ObservableCollection OpenExcuses { get; } = []; [ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private int _activeTabIndex; @@ -193,12 +224,15 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject [ObservableProperty] private int _excusedAbsenceCount; [ObservableProperty] private int _unexcusedAbsenceCount; [ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert"; + [ObservableProperty] private int _openExcuseOverflowCount; public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HasPrimaryRoster => PrimaryRoster.Count > 0; public bool HasSecondaryRoster => SecondaryRoster.Count > 0; public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0; public bool HasPatternNotices => PatternNotices.Count > 0; + public bool HasOpenExcuses => OpenExcuses.Count > 0; + public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0; public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool AllFilterSelected => SelectedRosterFilter == 2; @@ -235,15 +269,22 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public Func? OnNavigateToWorkload { get; set; } public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, - UntisReportCacheService cache, SchoolYearService schoolYear, ClassTeacherDetailsViewModel detailsTab) + UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, + IStudentRepository students, IParticipationRepository participation, + IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab) { _settings = settings; _cache = cache; _schoolYear = schoolYear; + _workTasks = workTasks; + _students = students; + _participation = participation; + _participationSessions = participationSessions; DetailsTab = detailsTab; } partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); + partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow)); partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSelectedRosterFilterChanged(int value) { @@ -259,6 +300,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject HomeroomClassName = _settings.HomeroomClassName; ActiveTabIndex = 0; Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); + OpenExcuses.Clear(); OpenExcuseOverflowCount = 0; if (!HomeroomClassConfigured) { Status = "Noch keine Klasse ausgewählt."; @@ -302,6 +344,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject RecentClassRegisterCount = classRegisterTask.Result.Count; BuildTrend(absenceDaysYear, trendDays); BuildPatternNotices(weekAbsenceDays); + BuildWeekdayPatternNotices(absenceDaysYear); + BuildAttendanceParticipationNotices(); + BuildOpenExcuses(absenceDaysYear, today); LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; ApplyRosterFilter(); @@ -436,6 +481,174 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject OnPropertyChanged(nameof(HasPatternNotices)); } + /// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten + /// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren + /// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares + /// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7 + /// Tagen. Wer schon eine Notiz aus hat, wird hier + /// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt. + private void BuildWeekdayPatternNotices(IReadOnlyList absenceDays) + { + var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName)) + .ToDictionary(g => g.Key, g => g.First().StudentName); + var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet(); + foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted)) + PatternNotices.Add(notice); + OnPropertyChanged(nameof(HasPatternNotices)); + } + + /// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie + /// ). verhindert, + /// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten; + /// verlangt eine deutliche Häufung, nicht nur eine leichte + /// Mehrheit. + public static IReadOnlyList DetectWeekdayPatterns( + IReadOnlyList absenceDays, + IReadOnlyDictionary displayNamesByKey, + ISet excludeDisplayNames, + int minSampleSize = 3, double clusterThreshold = 0.6) + { + var notices = new List(); + foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName))) + { + var rows = group.ToList(); + if (rows.Count < minSampleSize) continue; + var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName; + if (excludeDisplayNames.Contains(displayName)) continue; + + var top = rows.GroupBy(r => r.Date.DayOfWeek) + .Select(g => (Day: g.Key, Count: g.Count())) + .OrderByDescending(g => g.Count).First(); + if ((double)top.Count / rows.Count < clusterThreshold) continue; + + notices.Add(new ClassTeacherPatternNotice(displayName, + $"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}", + ClassTeacherStatusKind.Info)); + } + return notices; + } + + /// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das + /// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote + /// () mit dem Mitarbeit-Trend aus + /// dem Dokumentations-/Mitarbeitsmodul (, gruppen- + /// übergreifend über GetByStudent, gleiches Muster wie die Fehlzeitenbilanz in + /// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote + /// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche + /// simple Heuristik wie ParticipationGradeRow.ComputeTrend), sonst würde die Liste bei + /// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb + /// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden + /// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow): + /// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt + /// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist + /// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren. + private const int CorrelationAbsenceRateThreshold = 15; + private void BuildAttendanceParticipationNotices() + { + var students = _students.GetAll(); + foreach (var row in Roster.Where(r => r.HasYearSummary && + r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold)) + { + if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue; + var student = MatchStudent(row.StudentName, students); + if (student is null) continue; + + var points = _participation.GetByStudent(student.Id) + .Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e)) + .Where(x => x.Session is not null && x.Entry.Ratings.Count > 0) + .OrderBy(x => x.Session!.Date) + .TakeLast(8) + .Select(x => x.Entry.Ratings.Average(r => r.Value)) + .ToList(); + if (points.Count < 4) continue; + + var mid = points.Count / 2; + var diff = points.Skip(mid).Average() - points.Take(mid).Average(); + if (diff <= -0.4) + PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName, + $"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit", + ClassTeacherStatusKind.Info)); + } + OnPropertyChanged(nameof(HasPatternNotices)); + } + + /// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt FirstName/LastName + /// statt , weil dessen "Nachname, Vorname"-Format mit Komma den + /// leerzeichenbasierten Wortabgleich in verfälschen würde + /// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen). + public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList students) + { + var key = UntisNameMatching.NameKey(webUntisDisplayName); + return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key); + } + + private static string WeekdayLabel(DayOfWeek day) => day switch + { + DayOfWeek.Monday => "Montag", + DayOfWeek.Tuesday => "Dienstag", + DayOfWeek.Wednesday => "Mittwoch", + DayOfWeek.Thursday => "Donnerstag", + DayOfWeek.Friday => "Freitag", + DayOfWeek.Saturday => "Samstag", + _ => "Sonntag", + }; + + /// Zeigt höchstens Zeilen — Nutzer-Feedback zu den + /// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest + /// zählt in und wird als "+N weitere" angezeigt. + private const int OpenExcuseDisplayLimit = 6; + + private void BuildOpenExcuses(IReadOnlyList absenceDays, DateOnly today) + { + var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today); + foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row); + OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit); + } + + [RelayCommand] + private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row) + { + if (row is null) return; + DetailsTab.StudentFilter = row.StudentName; + ActiveTabIndex = 2; + DetailsTab.LoadCommand.Execute(null); + } + + /// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche + /// Erstellen ("Eltern anrufen – Ada Müller") musste man dort noch einmal von Hand eintippen. + /// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen — + /// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe + /// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist + /// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben). + [RelayCommand] + private void CreateReminderForStudent(ClassTeacherRosterRow? row) + { + if (row is null) return; + SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused); + } + + [RelayCommand] + private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row) + { + if (row is null) return; + SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).", + urgent: row.IsOverdue); + } + + private void SaveReminder(string studentName, string notes, bool urgent) + { + var task = new WorkTask + { + Title = $"Eltern kontaktieren – {studentName}", + Notes = notes, + Kind = TaskKind.Reminder, + Priority = urgent ? TaskPriority.High : TaskPriority.Normal, + DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1), + }; + _workTasks.Save(task); + Status = $"Wiedervorlage „{task.Title}\" angelegt — zu finden unter Aufgaben & Wiedervorlagen."; + } + private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount); private void NotifySummary() { @@ -452,6 +665,6 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject private void NotifyRosterState() { OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster)); - OnPropertyChanged(nameof(HasNoFilterResults)); + OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses)); } } diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index 2928f6e..c7aec4a 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -44,6 +44,21 @@ + + - + @@ -58,7 +58,35 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -77,6 +105,6 @@ - + diff --git a/TODO.md b/TODO.md index 21249b6..1334054 100644 --- a/TODO.md +++ b/TODO.md @@ -1479,6 +1479,56 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk Gefahr > Warnung > Info > Ok). Da die Farben über die geteilten `AppStatus*Brush`-Ressourcen laufen, wirkt die Korrektur automatisch überall im Klassenlehrer-Bereich (Kennzahlkarten, Roster-Status, Chips in Fehlzeiten-/Klassenbuchansicht), nicht nur in den beiden Charts. +- [x] **"Klassenlehrer"-Feature — vier Feature-Ideen umgesetzt (August 2026):** Aus derselben + Vorschlagsliste wie die Quick-Wins oben, auf Nutzerwunsch alle außer dem Elterngesprächs-PDF + (dafür fehlt noch ein geschärftes Konzept). + - **Offene Entschuldigungen mit Frist:** neue Karte in der Seitenspalte, listet aktuell + unentschuldigte Fehltage über den gesamten geladenen Zeitraum (nicht nur heute), sortiert nach + Tagen offen absteigend, auf 6 Zeilen gedeckelt ("+N weitere"). `ClassTeacherOpenExcuseRow` + markiert Einträge über `DeadlineDays = 3` als überfällig (Richtwert für die häufige + "innerhalb von drei Tagen"-Regelung, z.B. § 43 Abs. 2 SchulG NRW — keine rechtlich geprüfte, + schulspezifische Frist, bei Bedarf anpassen). Läuft über die ohnehin für die Jahresfehlquote + geladenen Daten, kein zusätzlicher Abruf. + - **Aggregierte Klassenbuch-Kategorien:** neue Chip-Reihe über der Tabelle im Klassenbuch-Tab + ("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller" statt nur einer Gesamtzahl). + `ClassTeacherCategoryAggregateRow.Build` gruppiert die bereits geladenen `Entries` nach + Kategorie, rein clientseitig. + - **Erweiterte Mustererkennung — Wochentags-Häufung:** dritte Regel neben den bestehenden + (≥2 unentschuldigt/verspätet in 7 Tagen): wenn ≥60 % der Fehltage eines/einer Schülers/-in + seit Schuljahresbeginn (Mindeststichprobe 3) auf denselben Wochentag fallen, ein Hinweis + ("3 von 4 Fehltagen an einem Montag"). Wer schon eine Notiz aus der bestehenden Regel hat, + wird ausgelassen, damit niemand doppelt auftaucht. Kernlogik als + `ClassTeacherOverviewViewModel.DetectWeekdayPatterns` bewusst statisch/ohne ViewModel-Zustand + (gleiches Muster wie `ClassTeacherRosterRow.Build`) und dadurch direkt testbar. + - **Aufgabe/Wiedervorlage direkt aus der Zeile:** neuer kleiner "+"-Button neben jeder + Roster-Zeile und jeder Zeile in "Offene Entschuldigungen" legt sofort eine `WorkTask` + (`Kind = Reminder`, wie 6.1.1, bewusst ohne `GroupId` — Klassenlehrer ist man für die ganze + Klasse) mit vorbefülltem Titel "Eltern kontaktieren – {Name}" an, ohne Dialog. **Wichtiger + XAML-Fallstrick dabei:** ein `ContextMenu` auf dem Zeilen-`Button` war der naheliegendere + erste Ansatz, scheitert aber an `$parent`-Vorfahren-Bindings — das `ContextMenu`-Popup hängt + nicht im normalen visuellen Baum unter dem `ItemsControl`, `$parent[ItemsControl]` findet das + ViewModel dort nicht. Stattdessen zwei Geschwister-`Button` in einem `Grid` (der bestehende + Zeilen-Button bleibt unverändert, der neue "+"-Button daneben hat ganz regulären + Vorfahren-Zugriff). + - **Verknüpfung mit Noten/Mitarbeit:** bewusst als dritte Regel im "Muster erkannt"-Panel statt + als Dauerzusatz in jeder Zeile (Overload vermeiden) — nur bei bereits spürbar erhöhter + Jahresfehlquote (≥15 %) UND eindeutig fallendem Mitarbeit-Trend über die letzten bis zu 8 + Bewertungen (gleiche einfache Erste-Hälfte/Zweite-Hälfte-Heuristik wie + `ParticipationGradeRow.ComputeTrend`). Namensabgleich WebUntis→internem `Student` über + `ClassTeacherOverviewViewModel.MatchStudent` (öffentlich statt `internal`, wie der Rest der + Codebasis es hält, siehe `UntisSyncService`-Kommentar — kein `InternalsVisibleTo` + konfiguriert). **Wichtiger Fallstrick:** `Student.FullName` liefert "Nachname, Vorname" mit + Komma — ein Abgleich darüber hätte den leerzeichenbasierten Wortvergleich in + `UntisNameMatching` verfälscht ("Müller," bliebe eigenes Wort). `MatchStudent` baut den + Vergleichsnamen deshalb explizit aus `FirstName`/`LastName` zusammen, nicht aus `FullName`. + Mitarbeit-Rohwerte werden bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in + `ParticipationGradeViewModels`) — die Aspekt-Konfiguration ist je Lerngruppe, der + Klassenlehrer-Bereich kennt aber keine einzelne Gruppe; ein einfacher Rohwert-Durchschnitt ist + hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren. + Neue Abhängigkeiten in `ClassTeacherOverviewViewModel`: `IWorkTaskRepository`, + `IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon + als Singleton registriert, nur Konstruktor-Injection ergänzt). + **Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug. ### 4.4 Wochen-/Tagesansicht - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3