diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 1fb7a4b..52885e8 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -84,6 +84,11 @@ public sealed class DashboardViewModelTests private static IEnumerable Items(DashboardViewModel vm, AttentionKind kind) => vm.Attention.Where(g => g.Kind == kind).SelectMany(g => g.Items); + /// Entschärfte Warnungen stehen nicht in ihrer Art-Gruppe, sondern gesammelt in der + /// "Zurückgestellt"-Gruppe am Ende. + private static IEnumerable DeferredItems(DashboardViewModel vm) => + vm.Attention.Where(g => g.IsDeferred).SelectMany(g => g.Items); + /// AttentionItem traegt fuer MissingTeachingTime kein eigenes Date-Feld (Title ist bereits der /// formatierte Anzeigetext) — das genaue Datum steckt im mitgegebenen Action-Parameter /// (derselbe MissingTeachingTimeItem, den auch der "Erfassen"-Dialog erhaelt). @@ -973,7 +978,7 @@ public sealed class DashboardViewModelTests } [Fact] - public void AttendanceWarnings_MitKuerzlichBearbeitetemVorgang_IstEntschaerftUndAmEndeSortiert() + public void AttendanceWarnings_MitKuerzlichBearbeitetemVorgang_WandertInZurueckgestelltGruppe() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; @@ -992,12 +997,13 @@ public sealed class DashboardViewModelTests students: new FakeStudents([handled, active]), sessions: sessions, entries: entries, vorgaenge: vorgaenge); - var items = Items(vm, AttentionKind.Attendance).ToList(); - Assert.Equal(2, items.Count); - Assert.False(items[0].IsDefused); - Assert.Equal(active.FullName, items[0].Title); - Assert.True(items[1].IsDefused); - Assert.Equal(handled.FullName, items[1].Title); + var item = Assert.Single(Items(vm, AttentionKind.Attendance)); + Assert.False(item.IsDefused); + Assert.Equal(active.FullName, item.Title); + var deferred = Assert.Single(DeferredItems(vm)); + Assert.True(deferred.IsDefused); + Assert.Equal(handled.FullName, deferred.Title); + Assert.Equal("Fehlzeiten · Vorgang in Bearbeitung", deferred.Subtitle); } [Fact] @@ -1040,9 +1046,63 @@ public sealed class DashboardViewModelTests students: new FakeStudents([handled, active]), sessions: sessions, entries: entries, warningSnoozes: snoozes); - var byName = Items(vm, AttentionKind.Attendance).ToDictionary(i => i.Title); - Assert.True(byName[handled.FullName].IsDefused); - Assert.False(byName[active.FullName].IsDefused); + Assert.Equal(active.FullName, Assert.Single(Items(vm, AttentionKind.Attendance)).Title); + var deferred = Assert.Single(DeferredItems(vm)); + Assert.Equal(handled.FullName, deferred.Title); + Assert.Equal($"Fehlzeiten · zurückgestellt bis {today.AddDays(7):dd.MM.}", deferred.Subtitle); + } + + [Fact] + public void ZurueckgestelltGruppe_StehtEingeklapptAmEndeUndZaehltNichtMit() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var (handled, active) = BuildTwoOverThresholdStudents(group, today, sessions, entries); + var snoozes = new FakeDashboardWarningSnoozeRepository(); + snoozes.Save(new DashboardWarningSnooze { StudentId = handled.Id, WarningTag = "Absentismus", SnoozedUntil = today.AddDays(7) }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([handled, active]), sessions: sessions, entries: entries, + warningSnoozes: snoozes); + + var last = vm.Attention.Last(); + Assert.True(last.IsDeferred); + Assert.Null(last.Kind); + Assert.False(last.IsExpanded); + Assert.Equal(1, vm.AttentionCount); + Assert.True(vm.HasOpenAttention); + } + + [Fact] + public void ZurueckgestelltGruppe_AufklappzustandUeberlebtNeuaufbauUndFilterBlendetAus() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var (handled, active) = BuildTwoOverThresholdStudents(group, today, sessions, entries); + var snoozes = new FakeDashboardWarningSnoozeRepository(); + snoozes.Save(new DashboardWarningSnooze { StudentId = handled.Id, WarningTag = "Absentismus", SnoozedUntil = today.AddDays(7) }); + snoozes.Save(new DashboardWarningSnooze { StudentId = active.Id, WarningTag = "Absentismus", SnoozedUntil = today.AddDays(7) }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([handled, active]), sessions: sessions, entries: entries, + warningSnoozes: snoozes); + + // Nur noch zurückgestellte Warnungen: kein offener Punkt, Kachel bleibt aber erreichbar. + Assert.Equal(0, vm.AttentionCount); + Assert.False(vm.HasOpenAttention); + Assert.False(vm.AttentionCard.IsEmpty); + + vm.Attention.Single(g => g.IsDeferred).ToggleExpandedCommand.Execute(null); + var filter = vm.AttentionFilters.Single(f => f.Kind == AttentionKind.Attendance); + filter.IsActive = false; + Assert.DoesNotContain(vm.Attention, g => g.IsDeferred); + filter.IsActive = true; + + Assert.True(vm.Attention.Single(g => g.IsDeferred).IsExpanded); } [Fact] @@ -1078,7 +1138,9 @@ public sealed class DashboardViewModelTests Assert.Equal(student.Id, saved.StudentId); Assert.Equal("Absentismus", saved.WarningTag); Assert.Equal(today.AddDays(14), saved.SnoozedUntil); - Assert.True(Assert.Single(Items(vm, AttentionKind.Attendance)).IsDefused); + Assert.Empty(Items(vm, AttentionKind.Attendance)); + Assert.True(Assert.Single(DeferredItems(vm)).IsDefused); + Assert.Equal(0, vm.AttentionCount); } [Fact] diff --git a/LehrerApp.Desktop/ViewModels/AttentionItem.cs b/LehrerApp.Desktop/ViewModels/AttentionItem.cs index 826ef5e..ae48fae 100644 --- a/LehrerApp.Desktop/ViewModels/AttentionItem.cs +++ b/LehrerApp.Desktop/ViewModels/AttentionItem.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using System.Windows.Input; namespace LehrerApp.Desktop.ViewModels; @@ -53,7 +54,8 @@ public sealed class AttentionItem /// passendem Tag wurde kürzlich bearbeitet, oder die Warnung wurde manuell zurückgestellt (siehe /// DashboardViewModel.SnoozeWarningAsync) — beides zeitlich befristet, damit eine ausbleibende /// Besserung nach Ablauf der Frist wieder auffällt statt dauerhaft entschärft zu bleiben. Steuert - /// nur die Optik (grau statt rot) und die Sortierung (ans Ende der Gruppe); der Schwellenwert- + /// die Optik (grau statt rot), die Einordnung in die eingeklappte "Zurückgestellt"-Gruppe und + /// das Herausnehmen aus DashboardViewModel.AttentionCount; der Schwellenwert- /// Vergleich selbst bleibt unverändert, die Warnung verschwindet also ganz, sobald sich die /// zugrunde liegende Zahl tatsächlich bessert. public bool IsDefused { get; } @@ -109,13 +111,34 @@ public sealed class AttentionItem /// Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten /// die Sortierung, die ihre jeweilige LoadXxx-Methode schon immer produziert hat (z.B. Fehlzeiten /// nach Quote absteigend, Korrekturen nach Datum) — ein zweites, generisches "Importance"-Sortieren -/// quer über alle Arten würde diese bewusst gewählten Reihenfolgen wieder zerstören. -public sealed class AttentionGroup(AttentionKind kind, string header, IReadOnlyList items) +/// quer über alle Arten würde diese bewusst gewählten Reihenfolgen wieder zerstören. +/// Sonderfall IsDeferred: die "Zurückgestellt"-Gruppe am Ende sammelt entschärfte Warnungen aller +/// drei klassenübergreifenden Arten (Kind deshalb null) und ist ein-/ausklappbar. +public sealed partial class AttentionGroup : ObservableObject { - public AttentionKind Kind { get; } = kind; - public string Header { get; } = header; - public IReadOnlyList Items { get; } = items; + public AttentionKind? Kind { get; } + public string Header { get; } + public IReadOnlyList Items { get; } public int Count => Items.Count; + public bool IsDeferred { get; } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ExpandGlyph))] private bool _isExpanded; + public string ExpandGlyph => IsExpanded ? "▾" : "▸"; + private readonly Action? _onExpandedChanged; + + public AttentionGroup(AttentionKind? kind, string header, IReadOnlyList items, + bool isDeferred = false, bool isExpanded = true, Action? onExpandedChanged = null) + { + Kind = kind; + Header = header; + Items = items; + IsDeferred = isDeferred; + _isExpanded = isExpanded; + _onExpandedChanged = onExpandedChanged; + } + + partial void OnIsExpandedChanged(bool value) => _onExpandedChanged?.Invoke(value); + + [RelayCommand] private void ToggleExpanded() => IsExpanded = !IsExpanded; } /// Ein Filter-Chip für eine Handlungsbedarf-Art in der zusammengefassten Karte — ersetzt diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 6e1efff..9b2d492 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -66,8 +66,8 @@ public partial class DashboardViewModel : ObservableObject /// (alle Gruppen) wird gewarnt. private const int LatenessWarningThreshold = 8; /// Nutzer-Feedback: eine Warnung, zu der bereits ein offener Vorgang mit passendem Tag existiert - /// ODER die manuell zurückgestellt wurde, erscheint "entschärft" (grau, ans Ende der Gruppe - /// einsortiert) statt weiter rot hervorgehoben — aber nur befristet: bessert sich die zugrunde + /// ODER die manuell zurückgestellt wurde, erscheint "entschärft" (grau, in der eingeklappten + /// "Zurückgestellt"-Gruppe, nicht mitgezählt) statt weiter rot hervorgehoben — aber nur befristet: bessert sich die zugrunde /// liegende Zahl innerhalb dieser Frist nicht, soll die Warnung wieder auffallen, statt durch /// einen dauerhaft offenen Vorgang für immer entschärft zu bleiben. Für den Vorgang-Fall zählt /// dessen UpdatedAt (jede Interaktion setzt die Frist zurück); für "Zurückstellen" wählt die @@ -194,9 +194,16 @@ public partial class DashboardViewModel : ObservableObject public int TodayLessonCount => TodaysLessons.Count; public int OpenTaskCount => OpenTasks.Count; public int UpcomingCount => UpcomingDates.Count; - public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count + /// Zurückgestellte/entschärfte Warnungen zählen bewusst nicht mit (Nutzer-Feedback) — sie stehen + /// eingeklappt in der eigenen "Zurückgestellt"-Gruppe, bis ihre Frist abläuft. + public int AttentionCount => _excuses.Count + _supportItems.Count + _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count - + _missingHomeworkItems.Count + _latenessItems.Count; + + CrossClassWarnings.Count(i => !i.IsDefused); + /// Steuert den Hinweis "Keine offenen Punkte." — die eingeklappte "Zurückgestellt"-Gruppe allein + /// gilt nicht als offener Punkt. + public bool HasOpenAttention => Attention.Any(g => !g.IsDeferred); + private IEnumerable CrossClassWarnings => + _attendanceItems.Concat(_latenessItems).Concat(_missingHomeworkItems); public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden"; public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben"; public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; @@ -350,7 +357,9 @@ public partial class DashboardViewModel : ObservableObject UpcomingCard.IsEmpty = UpcomingDates.Count == 0; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0; - AttentionCard.IsEmpty = AttentionCount == 0; + // Nicht über AttentionCount: die Kachel muss auch dann stehen bleiben, wenn nur noch + // zurückgestellte Warnungen übrig sind, sonst wären diese bis zum Fristablauf unerreichbar. + AttentionCard.IsEmpty = AttentionCount == 0 && !CrossClassWarnings.Any(); // Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt // ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung. @@ -427,8 +436,8 @@ public partial class DashboardViewModel : ObservableObject // ── Entschärfen (Nutzer-Feedback) ───────────────────────────────────────── // - // Eine der drei klassenübergreifenden Warnungen gilt als "entschärft" (grau, ans Ende ihrer - // Gruppe sortiert), solange entweder ein offener Vorgang mit passendem Tag kürzlich bearbeitet + // Eine der drei klassenübergreifenden Warnungen gilt als "entschärft" (grau, in der + // eingeklappten "Zurückgestellt"-Gruppe, siehe RebuildAttention), solange entweder ein offener Vorgang mit passendem Tag kürzlich bearbeitet // wurde, oder eine manuelle Rückstellung noch läuft — beides zeitlich befristet (siehe // VorgangDefusalGraceDays-Kommentar), damit ausbleibende Besserung nach Fristablauf wieder // auffällt statt dauerhaft entschärft zu bleiben. Beide Nachschlage-Mengen bulk-geladen wie @@ -451,15 +460,24 @@ public partial class DashboardViewModel : ObservableObject return set; } - private HashSet<(Guid StudentId, string Tag)> BuildActiveSnoozeSet(DateOnly today) => + // Bei doppelten Einträgen für dasselbe Paar (z.B. Altbestand vor der abgeleiteten Id) zählt die + // längste noch laufende Frist. + private Dictionary<(Guid StudentId, string Tag), DateOnly> BuildActiveSnoozeSet(DateOnly today) => _warningSnoozes.GetAll().Where(s => s.SnoozedUntil >= today) - .Select(s => (s.StudentId, s.WarningTag.ToLowerInvariant())).ToHashSet(); + .GroupBy(s => (s.StudentId, s.WarningTag.ToLowerInvariant())) + .ToDictionary(g => g.Key, g => g.Max(s => s.SnoozedUntil)); - private static bool IsDefused(Guid studentId, string tag, - HashSet<(Guid StudentId, string Tag)> recentVorgaenge, HashSet<(Guid StudentId, string Tag)> activeSnoozes) + /// Grund, warum eine Warnung entschärft ist, als Untertitel für die "Zurückgestellt"-Gruppe + /// (dort stehen alle drei Warnarten gemischt, deshalb mit Art-Präfix) — null, wenn sie scharf ist. + /// Eine manuelle Rückstellung hat Vorrang, weil sie ein konkretes Ablaufdatum nennen kann. + private static string? DefusalReason(Guid studentId, string tag, string kindLabel, + HashSet<(Guid StudentId, string Tag)> recentVorgaenge, + Dictionary<(Guid StudentId, string Tag), DateOnly> activeSnoozes) { var key = (studentId, tag.ToLowerInvariant()); - return recentVorgaenge.Contains(key) || activeSnoozes.Contains(key); + if (activeSnoozes.TryGetValue(key, out var until)) + return $"{kindLabel} · zurückgestellt bis {until:dd.MM.}"; + return recentVorgaenge.Contains(key) ? $"{kindLabel} · Vorgang in Bearbeitung" : null; } /// Von der "Zurückstellen"-Aktion einer Warnung aufgerufen (siehe ToAttentionItem-Überladungen @@ -514,15 +532,15 @@ public partial class DashboardViewModel : ObservableObject items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); } var ordered = items - .Select(i => (Item: i, Defused: IsDefused(i.StudentId, AbsentismusTag, recentVorgaenge, activeSnoozes))) - .OrderBy(x => x.Defused).ThenByDescending(x => x.Item.AbsenceRatePercent); + .Select(i => (Item: i, Defused: DefusalReason(i.StudentId, AbsentismusTag, "Fehlzeiten", recentVorgaenge, activeSnoozes))) + .OrderByDescending(x => x.Item.AbsenceRatePercent); foreach (var (item, defused) in ordered) _attendanceItems.Add(ToAttentionItem(item, defused)); } - private AttentionItem ToAttentionItem(AttendanceWarningItem w, bool isDefused) => new( + private AttentionItem ToAttentionItem(AttendanceWarningItem w, string? defusedReason) => new( AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %", - isWarningTrailing: true, isDefused: isDefused, + subtitle: defusedReason ?? "", isWarningTrailing: true, isDefused: defusedReason is not null, actions: [SnoozeAction(w.StudentId, AbsentismusTag, $"Fehlzeiten – {w.StudentName}")], navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, AbsentismusTag, "Fehlzeiten"), detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.Attendance, _evidence.Build(w.StudentId))); @@ -549,15 +567,15 @@ public partial class DashboardViewModel : ObservableObject items.Add(new MissingHomeworkWarningItem(student.Id, student.FullName, count)); } var ordered = items - .Select(i => (Item: i, Defused: IsDefused(i.StudentId, HausaufgabenTag, recentVorgaenge, activeSnoozes))) - .OrderBy(x => x.Defused).ThenByDescending(x => x.Item.MissingCount); + .Select(i => (Item: i, Defused: DefusalReason(i.StudentId, HausaufgabenTag, "Hausaufgaben", recentVorgaenge, activeSnoozes))) + .OrderByDescending(x => x.Item.MissingCount); foreach (var (item, defused) in ordered) _missingHomeworkItems.Add(ToAttentionItem(item, defused)); } - private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w, bool isDefused) => new( + private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w, string? defusedReason) => new( AttentionKind.MissingHomework, w.StudentName, trailingText: $"{w.MissingCount} offen", - isWarningTrailing: true, isDefused: isDefused, + subtitle: defusedReason ?? "", isWarningTrailing: true, isDefused: defusedReason is not null, actions: [SnoozeAction(w.StudentId, HausaufgabenTag, $"Hausaufgaben – {w.StudentName}")], navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, HausaufgabenTag, "Hausaufgaben"), detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.MissingHomework, _evidence.Build(w.StudentId))); @@ -588,15 +606,15 @@ public partial class DashboardViewModel : ObservableObject items.Add(new LatenessWarningItem(student.Id, student.FullName, count)); } var ordered = items - .Select(i => (Item: i, Defused: IsDefused(i.StudentId, VerspaetungenTag, recentVorgaenge, activeSnoozes))) - .OrderBy(x => x.Defused).ThenByDescending(x => x.Item.LateCount); + .Select(i => (Item: i, Defused: DefusalReason(i.StudentId, VerspaetungenTag, "Verspätungen", recentVorgaenge, activeSnoozes))) + .OrderByDescending(x => x.Item.LateCount); foreach (var (item, defused) in ordered) _latenessItems.Add(ToAttentionItem(item, defused)); } - private AttentionItem ToAttentionItem(LatenessWarningItem w, bool isDefused) => new( + private AttentionItem ToAttentionItem(LatenessWarningItem w, string? defusedReason) => new( AttentionKind.Lateness, w.StudentName, trailingText: $"{w.LateCount}× verspätet", - isWarningTrailing: true, isDefused: isDefused, + subtitle: defusedReason ?? "", isWarningTrailing: true, isDefused: defusedReason is not null, actions: [SnoozeAction(w.StudentId, VerspaetungenTag, $"Verspätungen – {w.StudentName}")], navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, VerspaetungenTag, "Verspätungen"), detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.Lateness, _evidence.Build(w.StudentId))); @@ -958,15 +976,27 @@ public partial class DashboardViewModel : ObservableObject }; Attention.Clear(); + var deferred = new List(); foreach (var (kind, header) in AttentionGroupOrder) { if (!IsAttentionFilterActive(kind)) continue; - var items = byKind[kind]; + var items = byKind[kind].Where(i => !i.IsDefused).ToList(); + deferred.AddRange(byKind[kind].Where(i => i.IsDefused)); if (items.Count == 0) continue; Attention.Add(new AttentionGroup(kind, header, items)); } + // Entschärfte Warnungen aller drei klassenübergreifenden Arten gesammelt ganz unten und + // eingeklappt (Nutzer-Feedback), statt grau in ihrer jeweiligen Gruppe mitzulaufen. Der + // Aufklapp-Zustand überlebt das Neuaufbauen der Gruppen (z.B. nach einem Zurückstellen). + if (deferred.Count > 0) + Attention.Add(new AttentionGroup(null, "Zurückgestellt", deferred, + isDeferred: true, isExpanded: _deferredGroupExpanded, + onExpandedChanged: expanded => _deferredGroupExpanded = expanded)); + OnPropertyChanged(nameof(HasOpenAttention)); } + private bool _deferredGroupExpanded; + private static AttentionItem ToAttentionItem(OpenExcuseItem e) => new( AttentionKind.Excuse, e.StudentName, subtitle: $"{e.GroupName} · {e.DateDisplay}", actions: [new AttentionAction("Entschuldigt", e.MarkExcusedCommand), diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 2e8363e..065f5a5 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -40,8 +40,8 @@ + statt rot in der eingeklappten "Zurückgestellt"-Gruppe, damit sie nicht mehr die gleiche + Dringlichkeit signalisiert, aber als Erinnerung erreichbar bleibt. Nach ".attendance" deklariert, damit sie bei gleicher Klassenanzahl gewinnt. -->