From 6af4bee1f0c8ff4c2200a7cb3ec85ca597d4edd0 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Fri, 11 Sep 2026 00:40:58 +0200 Subject: [PATCH] refactor: Dashboard-Handlungsbedarf zu einer Karte zusammenfassen Fasst die sieben Kacheln, die tatsaechlich "wo muss ich reagieren" beantworten (Entschuldigungen, Fehlzeiten-Warnung, Foerderplan-Wiedervorlage, Korrekturen, ungeplante Stunden, Auffaelligkeiten, Unterrichtszeit-Nacherfassung), zu einer Karte "Handlungsbedarf" mit Filter-Chips zusammen statt sieben eigener Sichtbarkeits-Schalter. Neues gemeinsames Modell AttentionItem/AttentionGroup/AttentionAction traegt nur Anzeigedaten - die Datenbeschaffung bleibt unveraendert in den jeweiligen DashboardViewModel.LoadXxx-Methoden, die am Ende ein AttentionItem statt ihrer eigenen Item-Klasse erzeugen. RebuildAttention() gruppiert nach Art (feste Reihenfolge) und wendet Filter an, ohne die Repos erneut abzufragen. SupportPlanDueItem, CorrectionProgressItem, UnplannedLessonItem und DashboardAlertItem entfallen (nur dashboard-intern verwendet); OpenExcuseItem und AttendanceWarningItem bleiben bestehen, da GroupOverviewViewModel sie weiterhin nutzt. Fuenf Navigations-Commands wurden durch ein einziges OpenAttentionItemCommand ersetzt. Bewusste Verhaltensaenderung: LoadAlerts dupliziert Fehlzeiten-Ueberschreitungen nicht mehr in die Auffaelligkeiten-Gruppe, da dieselbe Zahl sonst zweimal in derselben Karte erschiene (vorher durch zwei getrennte Kacheln nicht sichtbar). Details und Testanpassungen siehe TODO.md, Abschnitt 9 (Dashboard). --- .../Services/DashboardSettingsService.cs | 7 +- .../DashboardViewModelTests.cs | 72 ++-- LehrerApp.Desktop/ViewModels/AttentionItem.cs | 106 ++++++ .../ViewModels/DashboardViewModel.cs | 281 ++++++++-------- .../Views/Dashboard/DashboardView.axaml | 311 ++++++------------ TODO.md | 37 +++ 6 files changed, 447 insertions(+), 367 deletions(-) create mode 100644 LehrerApp.Desktop/ViewModels/AttentionItem.cs diff --git a/LehrerApp.Core/Services/DashboardSettingsService.cs b/LehrerApp.Core/Services/DashboardSettingsService.cs index 2476f04..67fe3d0 100644 --- a/LehrerApp.Core/Services/DashboardSettingsService.cs +++ b/LehrerApp.Core/Services/DashboardSettingsService.cs @@ -12,10 +12,13 @@ public sealed class DashboardCardSetting /// Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal. public sealed class DashboardSettingsService { + // "attention" fasst die frueheren sieben Kacheln missingteachingtime/excuses/corrections/ + // unplanned/alerts/attendance/support zusammen (siehe AttentionItem.cs im Desktop-Projekt). + // Alte gespeicherte dashboardsettings.json-Dateien mit den frueheren Keys sind unproblematisch: + // Load() unten verwirft unbekannte Keys ohnehin stillschweigend und ergaenzt neue als sichtbar. public static readonly string[] DefaultCardOrder = [ - "today", "tasks", "missingteachingtime", "calendar", "excuses", "upcoming", - "corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload", + "today", "tasks", "attention", "calendar", "upcoming", "groups", "examload", ]; private readonly string _configPath; diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 5e8fcf9..869e221 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -20,9 +20,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); - Assert.False(vm.ExcusesCard.EffectiveIsVisible); - Assert.False(vm.CorrectionsCard.EffectiveIsVisible); - Assert.False(vm.AlertsCard.EffectiveIsVisible); + Assert.False(vm.AttentionCard.EffectiveIsVisible); Assert.Equal("0 offene Punkte", vm.AttentionSummary); Assert.True(vm.TodayCard.EffectiveIsVisible); Assert.True(vm.CalendarCard.EffectiveIsVisible); @@ -40,7 +38,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); // Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen. - Assert.False(vm.ExcusesCard.EffectiveIsVisible); + Assert.False(vm.AttentionCard.EffectiveIsVisible); var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible) .Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList(); @@ -79,6 +77,18 @@ public sealed class DashboardViewModelTests Assert.Equal(new Avalonia.Thickness(0, 0, 8, 8), vm.TasksCard.Margin); } + /// Die frueheren sieben eigenen Listen (OpenExcuses, AttendanceWarnings, ...) sind zur + /// zusammengefassten Attention-Karte verschmolzen (siehe AttentionItem.cs) — Tests greifen + /// seither ueber Kind gefiltert zu statt ueber eine eigene Collection je Art. + private static IEnumerable Items(DashboardViewModel vm, AttentionKind kind) => + vm.Attention.Where(g => g.Kind == kind).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). + private static DateOnly MissingTimeDate(AttentionItem item) => + ((MissingTeachingTimeItem)item.Actions.Single().Parameter!).Date; + private static PeriodScheduleService NewPeriodSchedule() { var tempPath = System.IO.Path.Combine( @@ -204,9 +214,8 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); - Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); - Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible); - Assert.Equal(2, vm.AttentionCount); // fehlende Unterrichtszeit + bereits bestehende ungeplante Stunde + Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay); + Assert.True(vm.AttentionCard.EffectiveIsVisible); } [Fact] @@ -226,7 +235,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries); - Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); + Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay); } [Fact] @@ -246,7 +255,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule, substitutions: substitutions); - Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); + Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay); } [Fact] @@ -266,7 +275,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays); - Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); + Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay); } [Fact] @@ -284,7 +293,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); - Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today); + Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today); } [Fact] @@ -302,7 +311,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); - Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today); + Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today); } [Fact] @@ -429,10 +438,9 @@ public sealed class DashboardViewModelTests exams: new FakeExams([exam]), results: results, memberships: memberships, students: new FakeStudents([anna, ben])); - var correction = Assert.Single(vm.OpenCorrections); - Assert.Equal(1, correction.Completed); - Assert.Equal(2, correction.Total); - Assert.Equal(50, correction.Percent); + var correction = Assert.Single(Items(vm, AttentionKind.Correction)); + Assert.Equal(50, correction.ProgressPercent); + Assert.Equal("1 von 2 Arbeiten bewertet", correction.ProgressLabel); } [Fact] @@ -453,7 +461,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades, memberships: memberships, students: new FakeStudents([student])); - Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall"); + Assert.Contains(Items(vm, AttentionKind.Alert), a => a.Title == student.FullName && a.TrailingText == "Notenabfall"); } [Fact] @@ -612,9 +620,8 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, pastLesson, slots: slots); - var item = Assert.Single(vm.UnplannedLessons); - Assert.Equal(group.Id, item.GroupId); - Assert.Equal(1, item.PeriodNumber); + var item = Assert.Single(Items(vm, AttentionKind.Unplanned)); + Assert.Equal($"{group.Name} · 1. Stunde", item.Title); Assert.Equal("Heute", item.DateDisplay); } @@ -629,7 +636,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, lesson, slots: slots); - Assert.Empty(vm.UnplannedLessons); + Assert.Empty(Items(vm, AttentionKind.Unplanned)); } [Fact] @@ -643,7 +650,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, pastLesson, slots: slots); - Assert.Empty(vm.UnplannedLessons); + Assert.Empty(Items(vm, AttentionKind.Unplanned)); } [Fact] @@ -659,7 +666,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays); - Assert.Empty(vm.UnplannedLessons); + Assert.Empty(Items(vm, AttentionKind.Unplanned)); } [Fact] @@ -674,7 +681,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, lesson, slots: slots); - Assert.Empty(vm.UnplannedLessons); + Assert.Empty(Items(vm, AttentionKind.Unplanned)); } [Fact] @@ -689,9 +696,10 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, lesson, slots: slots); - Assert.Equal(2, vm.UnplannedLessons.Count); - Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3); - Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4); + var unplanned = Items(vm, AttentionKind.Unplanned).ToList(); + Assert.Equal(2, unplanned.Count); + Assert.Contains(unplanned, i => i.Title.Contains("3. Stunde")); + Assert.Contains(unplanned, i => i.Title.Contains("4. Stunde")); } [Fact] @@ -709,7 +717,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions); - Assert.Empty(vm.UnplannedLessons); + Assert.Empty(Items(vm, AttentionKind.Unplanned)); } [Fact] @@ -732,7 +740,7 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, students: new FakeStudents([student]), sessions: sessions, entries: entries); - Assert.Empty(vm.AttendanceWarnings); + Assert.Empty(Items(vm, AttentionKind.Attendance)); } [Fact] @@ -756,9 +764,9 @@ public sealed class DashboardViewModelTests var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, students: new FakeStudents([student]), sessions: sessions, entries: entries); - var item = Assert.Single(vm.AttendanceWarnings); - Assert.Equal(student.FullName, item.StudentName); - Assert.Equal(30.0, item.AbsenceRatePercent); + var item = Assert.Single(Items(vm, AttentionKind.Attendance)); + Assert.Equal(student.FullName, item.Title); + Assert.Equal("30 %", item.TrailingText); } [Fact] diff --git a/LehrerApp.Desktop/ViewModels/AttentionItem.cs b/LehrerApp.Desktop/ViewModels/AttentionItem.cs new file mode 100644 index 0000000..da08f01 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/AttentionItem.cs @@ -0,0 +1,106 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using System.Windows.Input; + +namespace LehrerApp.Desktop.ViewModels; + +/// Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten +/// (siehe ), zusammengefasst zu einer Karte mit +/// Filter-Chips statt sieben eigenen Sichtbarkeits-Schaltern. Reihenfolge hier ist die Reihenfolge +/// der Gruppen in der zusammengefassten Ansicht (siehe DashboardViewModel.RebuildAttention) — sie +/// übernimmt bewusst die frühere Sortierung aus DashboardSettingsService.DefaultCardOrder, damit +/// sich nichts, was Nutzer bereits kennen, unvorhersehbar umsortiert. +/// "Klausurwochen" (examload) bleibt bewusst außen vor: die Einträge dort sind Wochen-Aggregate, +/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell. +public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan } + +/// Eine Inline-Aktion auf einem Handlungsbedarf-Eintrag (z.B. "Entschuldigt"/"Unentschuldigt", +/// "Erfassen"). Verwendet ICommand statt Action, damit die bereits vorhandenen generierten +/// RelayCommands (OpenExcuseItem.MarkExcusedCommand, DashboardViewModel.AddMissingTeachingTimeCommand) +/// direkt weiterverwendet werden können, statt sie in einen zweiten Delegate-Typ zu verpacken. +public sealed record AttentionAction(string Label, ICommand Command, object? Parameter = null); + +/// Gemeinsame Projektion für die sieben Handlungsbedarf-Arten. Trägt nur Anzeigedaten — +/// die eigentliche Beschaffung bleibt unverändert in den jeweiligen DashboardViewModel.LoadXxx- +/// Methoden; sie erzeugen am Ende ein AttentionItem statt (wie vorher) ihre eigene Item-Klasse in +/// ihre eigene ObservableCollection einzutragen. +public sealed class AttentionItem +{ + public AttentionKind Kind { get; } + public string Title { get; } + public string Subtitle { get; } + /// Kurztext rechts neben dem Titel — Fehlzeitenquote, Alert-Kind-Label. Meist leer. + public string TrailingText { get; } + public string DateDisplay { get; } + public bool IsOverdue { get; } + public AlertSeverity? Severity { get; } + /// Nur bei Korrekturen gesetzt (0–100); steuert die ProgressBar. Eigenes HasProgress statt + /// Nullability, damit die ProgressBar-Bindung ein einfaches int (kompatibel mit Value:double) bleibt. + public int ProgressPercent { get; } + public bool HasProgress { get; } + /// Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt. + public string ProgressLabel { get; } + /// Färbt TrailingText wie die frühere Fehlzeiten-Warnung (fest "Foreground=Red") ein — + /// nur bei Attendance gesetzt, weil ein AttentionItem dieser Art per Konstruktion nur entsteht, + /// wenn die Fehlzeitenquote den Schwellenwert bereits überschreitet. + public bool IsWarningTrailing { get; } + public IReadOnlyList Actions { get; } + public Action? Navigate { get; } + + public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "", + string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null, + int progressPercent = 0, string progressLabel = "", bool hasProgress = false, + bool isWarningTrailing = false, IReadOnlyList? actions = null, Action? navigate = null) + { + Kind = kind; + Title = title; + Subtitle = subtitle; + TrailingText = trailingText; + DateDisplay = dateDisplay; + IsOverdue = isOverdue; + Severity = severity; + ProgressPercent = progressPercent; + ProgressLabel = progressLabel; + HasProgress = hasProgress; + IsWarningTrailing = isWarningTrailing; + Actions = actions ?? []; + Navigate = navigate; + } + + public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle); + public bool HasTrailingText => !string.IsNullOrWhiteSpace(TrailingText); + public bool HasDate => !string.IsNullOrWhiteSpace(DateDisplay); + public bool HasActions => Actions.Count > 0; + public bool HasSeverity => Severity is not null; + public bool IsHighSeverity => Severity == AlertSeverity.High; + public bool IsMediumSeverity => Severity == AlertSeverity.Medium; +} + +/// 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) +{ + public AttentionKind Kind { get; } = kind; + public string Header { get; } = header; + public IReadOnlyList Items { get; } = items; + public int Count => Items.Count; +} + +/// Ein Filter-Chip für eine Handlungsbedarf-Art in der zusammengefassten Karte — ersetzt +/// die frühere Sichtbarkeit je Einzelkachel (siehe DashboardViewModel.LoadAttentionFilters). +public partial class AttentionFilterOption : ObservableObject +{ + [ObservableProperty] private bool _isActive = true; + public AttentionKind Kind { get; } + public string Label { get; } + public Action? OnChanged { get; set; } + + public AttentionFilterOption(AttentionKind kind, string label) + { + Kind = kind; + Label = label; + } + + partial void OnIsActiveChanged(bool value) => OnChanged?.Invoke(); +} diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 70ddf85..d20ca5f 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -89,20 +89,43 @@ public partial class DashboardViewModel : ObservableObject public ObservableCollection OpenTasks { get; } = []; public ObservableCollection CurrentGroups { get; } = []; public ObservableCollection CalendarDays { get; } = []; - public ObservableCollection OpenExcuses { get; } = []; - public ObservableCollection AttendanceWarnings { get; } = []; public ObservableCollection ExamWeekLoads { get; } = []; - public ObservableCollection MissingTeachingTimeEntries { get; } = []; - public ObservableCollection SupportPlanReviews { get; } = []; public ObservableCollection UpcomingDates { get; } = []; - public ObservableCollection OpenCorrections { get; } = []; - public ObservableCollection UnplannedLessons { get; } = []; - public ObservableCollection Alerts { get; } = []; public ObservableCollection SelectedDayEvents { get; } = []; public ObservableCollection DashboardCards { get; } = []; public ObservableCollection WeatherWarnings { get; } = []; + // Zusammengefasste "Handlungsbedarf"-Karte (vormals sieben eigene Kacheln/Collections: + // Entschuldigungen, Fehlzeiten, Förderplan, Korrekturen, ungeplante Stunden, Auffälligkeiten, + // Unterrichtszeit-Nacherfassung — siehe AttentionItem.cs). Attention traegt nur, was nach + // Filterung (AttentionFilters) tatsaechlich angezeigt wird; die Rohdaten je Art liegen in den + // privaten _xyzItems-Feldern und werden von RebuildAttention() neu zusammengesetzt, sobald sich + // Daten oder Filter aendern — ohne die Repos erneut abzufragen. + public ObservableCollection Attention { get; } = []; + public ObservableCollection AttentionFilters { get; } = []; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + private readonly List _excuses = []; + private readonly List _attendanceItems = []; + private readonly List _supportItems = []; + private readonly List _correctionItems = []; + private readonly List _unplannedItems = []; + private readonly List _alertItems = []; + private readonly List _missingTimeItems = []; + + /// Reihenfolge und Überschriften der Gruppen in der zusammengefassten Handlungsbedarf-Karte — + /// übernimmt die frühere Kachel-Reihenfolge aus DashboardSettingsService.DefaultCardOrder, + /// damit sich für Nutzer nichts unvorhersehbar umsortiert. + private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder = + [ + (AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"), + (AttentionKind.Excuse, "Offene Entschuldigungen"), + (AttentionKind.Correction, "Offene Korrekturen"), + (AttentionKind.Unplanned, "Ungeplante Stunden"), + (AttentionKind.Alert, "Auffälligkeiten"), + (AttentionKind.Attendance, "Fehlzeiten-Warnung"), + (AttentionKind.SupportPlan, "Förderplan-Wiedervorlage"), + ]; + // Navigation-Callback – wird von App.axaml.cs verdrahtet public Action? OnNavigateToGroup { get; set; } public Action? OnNavigateToStudent { get; set; } @@ -125,21 +148,15 @@ public partial class DashboardViewModel : ObservableObject public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption CalendarCard => Card("calendar"); - public DashboardCardOption ExcusesCard => Card("excuses"); public DashboardCardOption UpcomingCard => Card("upcoming"); - public DashboardCardOption CorrectionsCard => Card("corrections"); - public DashboardCardOption UnplannedCard => Card("unplanned"); - public DashboardCardOption AlertsCard => Card("alerts"); - public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption ExamLoadCard => Card("examload"); - public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime"); - public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption GroupsCard => Card("groups"); + public DashboardCardOption AttentionCard => Card("attention"); public int TodayLessonCount => TodaysLessons.Count; public int OpenTaskCount => OpenTasks.Count; public int UpcomingCount => UpcomingDates.Count; - public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count - + OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count + MissingTeachingTimeEntries.Count; + public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count + + _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count; 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"; @@ -181,6 +198,7 @@ public partial class DashboardViewModel : ObservableObject annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); } LoadDashboardCards(); + LoadAttentionFilters(); Load(); RefreshWebUntisHealth(); } @@ -265,6 +283,7 @@ public partial class DashboardViewModel : ObservableObject LoadOpenCorrections(groups, today); LoadUnplannedLessons(groups, today); LoadAlerts(groups, today); + RebuildAttention(); UpdateDashboardSummary(); } @@ -273,16 +292,10 @@ public partial class DashboardViewModel : ObservableObject TodayCard.IsEmpty = TodaysLessons.Count == 0; TasksCard.IsEmpty = OpenTasks.Count == 0; CalendarCard.IsEmpty = false; - ExcusesCard.IsEmpty = OpenExcuses.Count == 0; UpcomingCard.IsEmpty = UpcomingDates.Count == 0; - CorrectionsCard.IsEmpty = OpenCorrections.Count == 0; - UnplannedCard.IsEmpty = UnplannedLessons.Count == 0; - AlertsCard.IsEmpty = Alerts.Count == 0; - AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; - MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0; - SupportCard.IsEmpty = SupportPlanReviews.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0; + AttentionCard.IsEmpty = AttentionCount == 0; // Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt // ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung. @@ -353,7 +366,7 @@ public partial class DashboardViewModel : ObservableObject private void LoadAttendanceWarnings(DateOnly today) { - AttendanceWarnings.Clear(); + _attendanceItems.Clear(); var schoolYear = _sy.CurrentSchoolYear(); var from = _sy.SchoolYearStart(schoolYear); var to = _sy.SchoolYearEnd(schoolYear); @@ -376,9 +389,13 @@ public partial class DashboardViewModel : ObservableObject items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); } foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) - AttendanceWarnings.Add(item); + _attendanceItems.Add(ToAttentionItem(item)); } + private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new( + AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %", + isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId)); + // ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── // // Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene @@ -410,7 +427,7 @@ public partial class DashboardViewModel : ObservableObject private void LoadMissingTeachingTime(DateOnly today) { - MissingTeachingTimeEntries.Clear(); + _missingTimeItems.Clear(); var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays); var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) @@ -445,14 +462,15 @@ public partial class DashboardViewModel : ObservableObject items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd)); } foreach (var item in items.OrderBy(i => i.Date)) - MissingTeachingTimeEntries.Add(item); + _missingTimeItems.Add(new AttentionItem(AttentionKind.MissingTeachingTime, item.DateDisplay, + actions: [new AttentionAction("Erfassen", AddMissingTeachingTimeCommand, item)])); } // ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── private void LoadSupportPlanReviews(DateOnly today) { - SupportPlanReviews.Clear(); + _supportItems.Clear(); var dueBy = today.AddDays(SupportPlanDueWithinDays); var due = _documentation.GetAll() @@ -465,8 +483,11 @@ public partial class DashboardViewModel : ObservableObject { var student = _students.GetById(d.StudentId); if (student is null) continue; - SupportPlanReviews.Add(new SupportPlanDueItem( - d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today)); + var reviewDate = d.SupportData!.ReviewDate!.Value; + var studentId = d.StudentId; + _supportItems.Add(new AttentionItem(AttentionKind.SupportPlan, student.FullName, subtitle: d.Title, + dateDisplay: reviewDate.ToString("dd.MM.yyyy"), isOverdue: reviewDate < today, + navigate: () => OnNavigateToStudent?.Invoke(studentId))); } } @@ -511,7 +532,7 @@ public partial class DashboardViewModel : ObservableObject private void LoadOpenCorrections(IReadOnlyDictionary groups, DateOnly today) { - OpenCorrections.Clear(); + _correctionItems.Clear(); foreach (var group in groups.Values) foreach (var exam in _exams.GetByGroup(group.Id) .Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) @@ -519,8 +540,13 @@ public partial class DashboardViewModel : ObservableObject { var (expected, evaluated) = ExamCorrectionCounter.Count(exam, _memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id)); - OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, - group.Name, exam.Date, evaluated, expected, today)); + var percent = expected == 0 ? 0 : (int)Math.Round(evaluated * 100.0 / expected); + var groupId = group.Id; + _correctionItems.Add(new AttentionItem(AttentionKind.Correction, exam.Title, subtitle: group.Name, + dateDisplay: exam.Date.ToString("dd.MM.yyyy"), + isOverdue: exam.Date < today.AddDays(-7) && evaluated < expected, + progressPercent: percent, progressLabel: $"{evaluated} von {expected} Arbeiten bewertet", hasProgress: true, + navigate: () => OnNavigateToExam?.Invoke(groupId))); } } @@ -536,14 +562,14 @@ public partial class DashboardViewModel : ObservableObject private void LoadUnplannedLessons(IReadOnlyDictionary groups, DateOnly today) { - UnplannedLessons.Clear(); + _unplannedItems.Clear(); var lastDay = today.AddDays(UnplannedLessonsLookaheadDays); var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .Select(h => h.Date).ToHashSet(); var schoolHolidays = _schoolHolidays.GetAll(); - var items = new List(); + var items = new List<(DateOnly Date, int PeriodNumber, LearningGroup Group)>(); foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning)) { var slots = _timetableSlots.GetByGroup(group.Id); @@ -572,12 +598,19 @@ public partial class DashboardViewModel : ObservableObject { continue; } - items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today)); + items.Add((date, slot.PeriodNumber, group)); } } } - foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName)) - UnplannedLessons.Add(item); + foreach (var (date, periodNumber, group) in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber) + .ThenBy(i => i.Group.Name)) + { + var dateDisplay = date == today ? "Heute" : date == today.AddDays(1) ? "Morgen" : date.ToString("dd.MM."); + var groupId = group.Id; + _unplannedItems.Add(new AttentionItem(AttentionKind.Unplanned, + $"{group.Name} · {periodNumber}. Stunde", dateDisplay: dateDisplay, + navigate: () => OnNavigateToUnplannedLesson?.Invoke(groupId))); + } } /// Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die @@ -609,11 +642,11 @@ public partial class DashboardViewModel : ObservableObject private void LoadAlerts(IReadOnlyDictionary groups, DateOnly today) { - Alerts.Clear(); - foreach (var warning in AttendanceWarnings) - Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName, - "Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High)); - + _alertItems.Clear(); + // Fehlzeiten-Auffälligkeiten erscheinen in der zusammengefassten Handlungsbedarf-Karte + // bereits als eigene Gruppe "Fehlzeiten-Warnung" (LoadAttendanceWarnings) — eine weitere + // Kopie hier wäre jetzt eine sichtbare Dopplung derselben Schüler/Zahl, die vor dem Merge + // durch zwei getrennte Kacheln (Auffälligkeiten vs. Fehlzeiten-Warnung) nicht auffiel. foreach (var group in groups.Values) { var groupReportGrades = _reportGrades.GetByGroup(group.Id); @@ -622,6 +655,7 @@ public partial class DashboardViewModel : ObservableObject { var student = _students.GetById(membership.StudentId); if (student is null) continue; + var studentId = student.Id; var values = _grades.GetByStudentAndGroup(student.Id, group.Id) .OrderBy(g => g.Date) .Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null) @@ -635,9 +669,10 @@ public partial class DashboardViewModel : ObservableObject ? recent - previous >= 1.0 : previous - recent >= 3.0; if (declined) - Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, - "Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}", - AlertSeverity.Medium)); + _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName, + subtitle: $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}", + trailingText: "Notenabfall", severity: AlertSeverity.Medium, + navigate: () => OnNavigateToStudent?.Invoke(studentId))); } var latestReport = groupReportGrades @@ -646,9 +681,10 @@ public partial class DashboardViewModel : ObservableObject var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue; if (int.TryParse(effective, out var reportValue) && (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4)) - Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, - "Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}", - AlertSeverity.High)); + _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName, + subtitle: $"{group.Name}: aktueller Stand {reportValue}", + trailingText: "Versetzungsgefährdung", severity: AlertSeverity.High, + navigate: () => OnNavigateToStudent?.Invoke(studentId))); } } } @@ -670,13 +706,65 @@ public partial class DashboardViewModel : ObservableObject private static string CardTitle(string key) => key switch { "today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender", - "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", - "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", - "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", - "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", - "missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key, + "upcoming" => "Anstehende Termine", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", + "attention" => "Handlungsbedarf", _ => key, }; + // ── Handlungsbedarf: Filter-Chips ───────────────────────────────────────── + // + // Ersetzt die frühere Sichtbarkeit je Einzelkachel (sieben Schalter im "Bereiche anpassen"- + // Panel) durch Filter-Chips innerhalb der zusammengefassten Karte — dichter, und der + // naheliegende Ort, weil alle sieben jetzt eine Karte sind. Bewusst nur für die Dauer der + // Sitzung (keine Persistenz über DashboardSettingsService): das JSON-Format dort ist eine + // flache Liste von Kachel-Einstellungen, eine zweite Objektform nur für diese sieben Filter + // hätte das Dateiformat aufgespalten, ohne dass "welche Handlungsbedarf-Art blende ich + // dauerhaft aus" bisher als Bedürfnis geäußert wurde. + + private void LoadAttentionFilters() + { + AttentionFilters.Clear(); + foreach (var (kind, header) in AttentionGroupOrder) + { + var option = new AttentionFilterOption(kind, header) { OnChanged = RebuildAttention }; + AttentionFilters.Add(option); + } + } + + private bool IsAttentionFilterActive(AttentionKind kind) => + AttentionFilters.FirstOrDefault(f => f.Kind == kind)?.IsActive ?? true; + + /// Setzt Attention aus den bereits geladenen _xyzItems-Feldern neu zusammen — reine + /// Umsortierung/Filterung im Speicher, kein Repo-Zugriff. Wird nach jedem Load() sowie nach + /// jeder punktuellen Änderung (Entschuldigung aufgelöst, Zeit nacherfasst, Filter-Chip + /// umgeschaltet) aufgerufen. + private void RebuildAttention() + { + var byKind = new Dictionary> + { + [AttentionKind.MissingTeachingTime] = _missingTimeItems, + [AttentionKind.Excuse] = _excuses.Select(ToAttentionItem).ToList(), + [AttentionKind.Correction] = _correctionItems, + [AttentionKind.Unplanned] = _unplannedItems, + [AttentionKind.Alert] = _alertItems, + [AttentionKind.Attendance] = _attendanceItems, + [AttentionKind.SupportPlan] = _supportItems, + }; + + Attention.Clear(); + foreach (var (kind, header) in AttentionGroupOrder) + { + if (!IsAttentionFilterActive(kind)) continue; + var items = byKind[kind]; + if (items.Count == 0) continue; + Attention.Add(new AttentionGroup(kind, header, items)); + } + } + + private static AttentionItem ToAttentionItem(OpenExcuseItem e) => new( + AttentionKind.Excuse, e.StudentName, subtitle: $"{e.GroupName} · {e.DateDisplay}", + actions: [new AttentionAction("Entschuldigt", e.MarkExcusedCommand), + new AttentionAction("Unentschuldigt", e.MarkUnexcusedCommand)]); + // Zaehlt bewusst EffectiveIsVisible, nicht IsVisible: eine eingeschaltete, aber gerade leere // HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen, // sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid. @@ -721,11 +809,10 @@ public partial class DashboardViewModel : ObservableObject SaveAndApplyCardLayout(); } - [RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item) - { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } - - [RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) - { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } + /// Ersetzt die früheren fünf eigenen Navigations-Commands (OpenStudentAttendance, + /// OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) — jedes AttentionItem + /// trägt sein Sprungziel bereits als Closure in Navigate. + [RelayCommand] private void OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke(); [RelayCommand] private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item) @@ -733,12 +820,13 @@ public partial class DashboardViewModel : ObservableObject if (item is null || OnAddMissingTeachingTime is null) return; await OnAddMissingTeachingTime(item); LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today)); + RebuildAttention(); UpdateDashboardSummary(); } private void LoadOpenExcuses(List groups, DateOnly today) { - OpenExcuses.Clear(); + _excuses.Clear(); var cutoff = today.AddDays(-OpenExcuseMaxAgeDays); var items = new List(); @@ -757,8 +845,7 @@ public partial class DashboardViewModel : ObservableObject } } } - foreach (var item in items.OrderBy(i => i.Date)) - OpenExcuses.Add(item); + _excuses.AddRange(items.OrderBy(i => i.Date)); } private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status) @@ -767,7 +854,8 @@ public partial class DashboardViewModel : ObservableObject if (entry is null) return; entry.Attendance = status; _participationEntries.Save(entry); - OpenExcuses.Remove(item); + _excuses.Remove(item); + RebuildAttention(); UpdateDashboardSummary(); } @@ -968,12 +1056,6 @@ public partial class DashboardViewModel : ObservableObject else OnNavigateToGroup?.Invoke(groupId); } } - [RelayCommand] private void OpenCorrection(CorrectionProgressItem? item) - { if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); } - [RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item) - { if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); } - [RelayCommand] private void OpenAlert(DashboardAlertItem? item) - { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } [RelayCommand] private void Refresh() => Load(); [RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false); @@ -1122,26 +1204,6 @@ public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOn public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De); } -// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────────── - -public class SupportPlanDueItem -{ - public Guid StudentId { get; } - public string StudentName { get; } - public string Title { get; } - public string ReviewDateDisplay { get; } - public bool IsOverdue { get; } - - public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today) - { - StudentId = studentId; - StudentName = studentName; - Title = title; - ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy"); - IsOverdue = reviewDate < today; - } -} - public partial class CalendarDayCell : ObservableObject { [ObservableProperty] private bool _isSelected; @@ -1217,46 +1279,8 @@ public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, strin }; } -public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName, - DateOnly date, int completed, int total, DateOnly today) -{ - public Guid ExamId { get; } = examId; - public Guid GroupId { get; } = groupId; - public string Title { get; } = title; - public string GroupName { get; } = groupName; - public DateOnly Date { get; } = date; - public int Completed { get; } = completed; - public int Total { get; } = total; - public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total); - public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet"; - public string DateDisplay => Date.ToString("dd.MM.yyyy"); - public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total; -} - -public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today) -{ - public Guid GroupId { get; } = groupId; - public string GroupName { get; } = groupName; - public DateOnly Date { get; } = date; - public int PeriodNumber { get; } = periodNumber; - public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM."); - public string Display => $"{GroupName} · {PeriodNumber}. Stunde"; -} - public enum AlertSeverity { Medium, High } -public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName, - string kindLabel, string detail, AlertSeverity severity) -{ - public Guid StudentId { get; } = studentId; - public Guid? GroupId { get; } = groupId; - public string StudentName { get; } = studentName; - public string KindLabel { get; } = kindLabel; - public string Detail { get; } = detail; - public AlertSeverity Severity { get; } = severity; - public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B"; -} - public partial class DashboardCardOption : ObservableObject { [ObservableProperty] private bool _isVisible; @@ -1281,8 +1305,7 @@ public partial class DashboardCardOption : ObservableObject { Key = key; Title = title; - HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned" - or "alerts" or "attendance" or "support"; + HideWhenEmpty = key is "upcoming" or "attention"; _isVisible = isVisible; } diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index a62b98e..452c79e 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -20,6 +20,28 @@ + + + + + + @@ -142,7 +164,8 @@ - + + - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - + + + + - - + + - - - - - - - - + - - + + + + + + + + + + + + + + + + + + + + + + + +