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).
This commit is contained in:
2026-09-11 00:40:58 +02:00
parent c3ce1a7204
commit 6af4bee1f0
6 changed files with 447 additions and 367 deletions
@@ -12,10 +12,13 @@ public sealed class DashboardCardSetting
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary> /// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
public sealed class DashboardSettingsService 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 = public static readonly string[] DefaultCardOrder =
[ [
"today", "tasks", "missingteachingtime", "calendar", "excuses", "upcoming", "today", "tasks", "attention", "calendar", "upcoming", "groups", "examload",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
]; ];
private readonly string _configPath; private readonly string _configPath;
@@ -20,9 +20,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.False(vm.ExcusesCard.EffectiveIsVisible); Assert.False(vm.AttentionCard.EffectiveIsVisible);
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
Assert.False(vm.AlertsCard.EffectiveIsVisible);
Assert.Equal("0 offene Punkte", vm.AttentionSummary); Assert.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible); Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.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 }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
// Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen. // 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) var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible)
.Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList(); .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); 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<AttentionItem> 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() private static PeriodScheduleService NewPeriodSchedule()
{ {
var tempPath = System.IO.Path.Combine( 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); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible); Assert.True(vm.AttentionCard.EffectiveIsVisible);
Assert.Equal(2, vm.AttentionCount); // fehlende Unterrichtszeit + bereits bestehende ungeplante Stunde
} }
[Fact] [Fact]
@@ -226,7 +235,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries); 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] [Fact]
@@ -246,7 +255,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions); 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] [Fact]
@@ -266,7 +275,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays); 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] [Fact]
@@ -284,7 +293,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); 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] [Fact]
@@ -302,7 +311,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); 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] [Fact]
@@ -429,10 +438,9 @@ public sealed class DashboardViewModelTests
exams: new FakeExams([exam]), results: results, memberships: memberships, exams: new FakeExams([exam]), results: results, memberships: memberships,
students: new FakeStudents([anna, ben])); students: new FakeStudents([anna, ben]));
var correction = Assert.Single(vm.OpenCorrections); var correction = Assert.Single(Items(vm, AttentionKind.Correction));
Assert.Equal(1, correction.Completed); Assert.Equal(50, correction.ProgressPercent);
Assert.Equal(2, correction.Total); Assert.Equal("1 von 2 Arbeiten bewertet", correction.ProgressLabel);
Assert.Equal(50, correction.Percent);
} }
[Fact] [Fact]
@@ -453,7 +461,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
memberships: memberships, students: new FakeStudents([student])); 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] [Fact]
@@ -612,9 +620,8 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots); var vm = BuildVm(group, pastLesson, slots: slots);
var item = Assert.Single(vm.UnplannedLessons); var item = Assert.Single(Items(vm, AttentionKind.Unplanned));
Assert.Equal(group.Id, item.GroupId); Assert.Equal($"{group.Name} · 1. Stunde", item.Title);
Assert.Equal(1, item.PeriodNumber);
Assert.Equal("Heute", item.DateDisplay); Assert.Equal("Heute", item.DateDisplay);
} }
@@ -629,7 +636,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -643,7 +650,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots); var vm = BuildVm(group, pastLesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -659,7 +666,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays); var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -674,7 +681,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -689,9 +696,10 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots); var vm = BuildVm(group, lesson, slots: slots);
Assert.Equal(2, vm.UnplannedLessons.Count); var unplanned = Items(vm, AttentionKind.Unplanned).ToList();
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3); Assert.Equal(2, unplanned.Count);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4); Assert.Contains(unplanned, i => i.Title.Contains("3. Stunde"));
Assert.Contains(unplanned, i => i.Title.Contains("4. Stunde"));
} }
[Fact] [Fact]
@@ -709,7 +717,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions); var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
Assert.Empty(vm.UnplannedLessons); Assert.Empty(Items(vm, AttentionKind.Unplanned));
} }
[Fact] [Fact]
@@ -732,7 +740,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries); students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(vm.AttendanceWarnings); Assert.Empty(Items(vm, AttentionKind.Attendance));
} }
[Fact] [Fact]
@@ -756,9 +764,9 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries); students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(vm.AttendanceWarnings); var item = Assert.Single(Items(vm, AttentionKind.Attendance));
Assert.Equal(student.FullName, item.StudentName); Assert.Equal(student.FullName, item.Title);
Assert.Equal(30.0, item.AbsenceRatePercent); Assert.Equal("30 %", item.TrailingText);
} }
[Fact] [Fact]
@@ -0,0 +1,106 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Windows.Input;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten
/// (siehe <see cref="DashboardViewModel.AttentionCount"/>), 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.</summary>
public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan }
/// <summary>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.</summary>
public sealed record AttentionAction(string Label, ICommand Command, object? Parameter = null);
/// <summary>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.</summary>
public sealed class AttentionItem
{
public AttentionKind Kind { get; }
public string Title { get; }
public string Subtitle { get; }
/// <summary>Kurztext rechts neben dem Titel — Fehlzeitenquote, Alert-Kind-Label. Meist leer.</summary>
public string TrailingText { get; }
public string DateDisplay { get; }
public bool IsOverdue { get; }
public AlertSeverity? Severity { get; }
/// <summary>Nur bei Korrekturen gesetzt (0100); steuert die ProgressBar. Eigenes HasProgress statt
/// Nullability, damit die ProgressBar-Bindung ein einfaches int (kompatibel mit Value:double) bleibt.</summary>
public int ProgressPercent { get; }
public bool HasProgress { get; }
/// <summary>Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt.</summary>
public string ProgressLabel { get; }
/// <summary>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.</summary>
public bool IsWarningTrailing { get; }
public IReadOnlyList<AttentionAction> 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<AttentionAction>? 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;
}
/// <summary>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.</summary>
public sealed class AttentionGroup(AttentionKind kind, string header, IReadOnlyList<AttentionItem> items)
{
public AttentionKind Kind { get; } = kind;
public string Header { get; } = header;
public IReadOnlyList<AttentionItem> Items { get; } = items;
public int Count => Items.Count;
}
/// <summary>Ein Filter-Chip für eine Handlungsbedarf-Art in der zusammengefassten Karte — ersetzt
/// die frühere Sichtbarkeit je Einzelkachel (siehe DashboardViewModel.LoadAttentionFilters).</summary>
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();
}
+152 -129
View File
@@ -89,20 +89,43 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<TaskItem> OpenTasks { get; } = []; public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = []; public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = []; public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = []; public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = []; public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = []; public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = []; public ObservableCollection<DashboardWeatherWarningItem> 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<AttentionGroup> Attention { get; } = [];
public ObservableCollection<AttentionFilterOption> AttentionFilters { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
private readonly List<OpenExcuseItem> _excuses = [];
private readonly List<AttentionItem> _attendanceItems = [];
private readonly List<AttentionItem> _supportItems = [];
private readonly List<AttentionItem> _correctionItems = [];
private readonly List<AttentionItem> _unplannedItems = [];
private readonly List<AttentionItem> _alertItems = [];
private readonly List<AttentionItem> _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 // Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Action<Guid>? OnNavigateToStudent { get; set; } public Action<Guid>? OnNavigateToStudent { get; set; }
@@ -125,21 +148,15 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption TasksCard => Card("tasks");
public DashboardCardOption CalendarCard => Card("calendar"); public DashboardCardOption CalendarCard => Card("calendar");
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming"); 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 ExamLoadCard => Card("examload");
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); public DashboardCardOption GroupsCard => Card("groups");
public DashboardCardOption AttentionCard => Card("attention");
public int TodayLessonCount => TodaysLessons.Count; public int TodayLessonCount => TodaysLessons.Count;
public int OpenTaskCount => OpenTasks.Count; public int OpenTaskCount => OpenTasks.Count;
public int UpcomingCount => UpcomingDates.Count; public int UpcomingCount => UpcomingDates.Count;
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count + MissingTeachingTimeEntries.Count; + _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count;
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden"; public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben"; public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; 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); annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
} }
LoadDashboardCards(); LoadDashboardCards();
LoadAttentionFilters();
Load(); Load();
RefreshWebUntisHealth(); RefreshWebUntisHealth();
} }
@@ -265,6 +283,7 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today); LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today); LoadAlerts(groups, today);
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
@@ -273,16 +292,10 @@ public partial class DashboardViewModel : ObservableObject
TodayCard.IsEmpty = TodaysLessons.Count == 0; TodayCard.IsEmpty = TodaysLessons.Count == 0;
TasksCard.IsEmpty = OpenTasks.Count == 0; TasksCard.IsEmpty = OpenTasks.Count == 0;
CalendarCard.IsEmpty = false; CalendarCard.IsEmpty = false;
ExcusesCard.IsEmpty = OpenExcuses.Count == 0;
UpcomingCard.IsEmpty = UpcomingDates.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; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0;
AttentionCard.IsEmpty = AttentionCount == 0;
// Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt // Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt
// ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung. // ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung.
@@ -353,7 +366,7 @@ public partial class DashboardViewModel : ObservableObject
private void LoadAttendanceWarnings(DateOnly today) private void LoadAttendanceWarnings(DateOnly today)
{ {
AttendanceWarnings.Clear(); _attendanceItems.Clear();
var schoolYear = _sy.CurrentSchoolYear(); var schoolYear = _sy.CurrentSchoolYear();
var from = _sy.SchoolYearStart(schoolYear); var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(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)); items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
} }
foreach (var item in items.OrderByDescending(i => i.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) ─────────────────────────────────────── // ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
// //
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene // 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) private void LoadMissingTeachingTime(DateOnly today)
{ {
MissingTeachingTimeEntries.Clear(); _missingTimeItems.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays); var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1) var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
@@ -445,14 +462,15 @@ public partial class DashboardViewModel : ObservableObject
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd)); items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
} }
foreach (var item in items.OrderBy(i => i.Date)) 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) ────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today) private void LoadSupportPlanReviews(DateOnly today)
{ {
SupportPlanReviews.Clear(); _supportItems.Clear();
var dueBy = today.AddDays(SupportPlanDueWithinDays); var dueBy = today.AddDays(SupportPlanDueWithinDays);
var due = _documentation.GetAll() var due = _documentation.GetAll()
@@ -465,8 +483,11 @@ public partial class DashboardViewModel : ObservableObject
{ {
var student = _students.GetById(d.StudentId); var student = _students.GetById(d.StudentId);
if (student is null) continue; if (student is null) continue;
SupportPlanReviews.Add(new SupportPlanDueItem( var reviewDate = d.SupportData!.ReviewDate!.Value;
d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today)); 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<Guid, LearningGroup> groups, DateOnly today) private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
OpenCorrections.Clear(); _correctionItems.Clear();
foreach (var group in groups.Values) foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id) foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) .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, var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id)); _memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, var percent = expected == 0 ? 0 : (int)Math.Round(evaluated * 100.0 / expected);
group.Name, exam.Date, evaluated, expected, today)); 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<Guid, LearningGroup> groups, DateOnly today) private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
UnplannedLessons.Clear(); _unplannedItems.Clear();
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays); var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1) var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet(); .Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll(); var schoolHolidays = _schoolHolidays.GetAll();
var items = new List<UnplannedLessonItem>(); var items = new List<(DateOnly Date, int PeriodNumber, LearningGroup Group)>();
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning)) foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
{ {
var slots = _timetableSlots.GetByGroup(group.Id); var slots = _timetableSlots.GetByGroup(group.Id);
@@ -572,12 +598,19 @@ public partial class DashboardViewModel : ObservableObject
{ {
continue; 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)) foreach (var (date, periodNumber, group) in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber)
UnplannedLessons.Add(item); .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)));
}
} }
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die /// <summary>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<Guid, LearningGroup> groups, DateOnly today) private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{ {
Alerts.Clear(); _alertItems.Clear();
foreach (var warning in AttendanceWarnings) // Fehlzeiten-Auffälligkeiten erscheinen in der zusammengefassten Handlungsbedarf-Karte
Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName, // bereits als eigene Gruppe "Fehlzeiten-Warnung" (LoadAttendanceWarnings) — eine weitere
"Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High)); // 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) foreach (var group in groups.Values)
{ {
var groupReportGrades = _reportGrades.GetByGroup(group.Id); var groupReportGrades = _reportGrades.GetByGroup(group.Id);
@@ -622,6 +655,7 @@ public partial class DashboardViewModel : ObservableObject
{ {
var student = _students.GetById(membership.StudentId); var student = _students.GetById(membership.StudentId);
if (student is null) continue; if (student is null) continue;
var studentId = student.Id;
var values = _grades.GetByStudentAndGroup(student.Id, group.Id) var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
.OrderBy(g => g.Date) .OrderBy(g => g.Date)
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null) .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 ? recent - previous >= 1.0
: previous - recent >= 3.0; : previous - recent >= 3.0;
if (declined) if (declined)
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
"Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}", subtitle: $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
AlertSeverity.Medium)); trailingText: "Notenabfall", severity: AlertSeverity.Medium,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
} }
var latestReport = groupReportGrades var latestReport = groupReportGrades
@@ -646,9 +681,10 @@ public partial class DashboardViewModel : ObservableObject
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue; var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
if (int.TryParse(effective, out var reportValue) if (int.TryParse(effective, out var reportValue)
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4)) && (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, _alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
"Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}", subtitle: $"{group.Name}: aktueller Stand {reportValue}",
AlertSeverity.High)); 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 private static string CardTitle(string key) => key switch
{ {
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender", "today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "upcoming" => "Anstehende Termine", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "attention" => "Handlungsbedarf", _ => key,
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => 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;
/// <summary>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.</summary>
private void RebuildAttention()
{
var byKind = new Dictionary<AttentionKind, IReadOnlyList<AttentionItem>>
{
[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 // Zaehlt bewusst EffectiveIsVisible, nicht IsVisible: eine eingeschaltete, aber gerade leere
// HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen, // HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen,
// sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid. // sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid.
@@ -721,11 +809,10 @@ public partial class DashboardViewModel : ObservableObject
SaveAndApplyCardLayout(); SaveAndApplyCardLayout();
} }
[RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item) /// <summary>Ersetzt die früheren fünf eigenen Navigations-Commands (OpenStudentAttendance,
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } /// OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) — jedes AttentionItem
/// trägt sein Sprungziel bereits als Closure in Navigate.</summary>
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) [RelayCommand] private void OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke();
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] [RelayCommand]
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item) private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
@@ -733,12 +820,13 @@ public partial class DashboardViewModel : ObservableObject
if (item is null || OnAddMissingTeachingTime is null) return; if (item is null || OnAddMissingTeachingTime is null) return;
await OnAddMissingTeachingTime(item); await OnAddMissingTeachingTime(item);
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today)); LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today) private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{ {
OpenExcuses.Clear(); _excuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays); var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>(); var items = new List<OpenExcuseItem>();
@@ -757,8 +845,7 @@ public partial class DashboardViewModel : ObservableObject
} }
} }
} }
foreach (var item in items.OrderBy(i => i.Date)) _excuses.AddRange(items.OrderBy(i => i.Date));
OpenExcuses.Add(item);
} }
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status) private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
@@ -767,7 +854,8 @@ public partial class DashboardViewModel : ObservableObject
if (entry is null) return; if (entry is null) return;
entry.Attendance = status; entry.Attendance = status;
_participationEntries.Save(entry); _participationEntries.Save(entry);
OpenExcuses.Remove(item); _excuses.Remove(item);
RebuildAttention();
UpdateDashboardSummary(); UpdateDashboardSummary();
} }
@@ -968,12 +1056,6 @@ public partial class DashboardViewModel : ObservableObject
else OnNavigateToGroup?.Invoke(groupId); 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 void Refresh() => Load();
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false); [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); 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 public partial class CalendarDayCell : ObservableObject
{ {
[ObservableProperty] private bool _isSelected; [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 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 public partial class DashboardCardOption : ObservableObject
{ {
[ObservableProperty] private bool _isVisible; [ObservableProperty] private bool _isVisible;
@@ -1281,8 +1305,7 @@ public partial class DashboardCardOption : ObservableObject
{ {
Key = key; Key = key;
Title = title; Title = title;
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned" HideWhenEmpty = key is "upcoming" or "attention";
or "alerts" or "attendance" or "support";
_isVisible = isVisible; _isVisible = isVisible;
} }
@@ -20,6 +20,28 @@
<Setter Property="BorderThickness" Value="2"/> <Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/> <Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
</Style> </Style>
<!-- Handlungsbedarf-Karte: Schweregrad-Streifen (nur Auffälligkeiten) und die rot
hervorgehobene Fehlzeitenquote — ersetzen die früheren fest kodierten Hex-Farben
(#D32F2F/#F59E0B, Foreground="Red") durch die validierte Status-Palette. -->
<Style Selector="Border.severitybar">
<Setter Property="Width" Value="4"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.severitybar.high">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.severitybar.medium">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.attentionTrailing">
<Setter Property="FontSize" Value="10"/>
<Setter Property="Opacity" Value="0.6"/>
</Style>
<Style Selector="TextBlock.attentionTrailing.warning">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Opacity" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<ScrollViewer Padding="24"> <ScrollViewer Padding="24">
@@ -142,7 +164,8 @@
<TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5" <TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
Margin="2,2,0,-8"/> Margin="2,2,0,-8"/>
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto"> <!-- 7 Kacheln (today/tasks/calendar/examload/upcoming/groups/attention) auf 2 Spalten -> 4 Zeilen. -->
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto">
<!-- Heutige Stunden --> <!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}" <Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
@@ -366,70 +389,6 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
<Border Grid.Column="{Binding ExcusesCard.Column}" Grid.Row="{Binding ExcusesCard.Row}"
IsVisible="{Binding ExcusesCard.EffectiveIsVisible}" Margin="{Binding ExcusesCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="OFFENE ENTSCHULDIGUNGEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenExcuses}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:OpenExcuseItem">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock FontSize="11" Opacity="0.6">
<Run Text="{Binding GroupName}"/>
<Run Text=" · "/>
<Run Text="{Binding DateDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Entschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkExcusedCommand}" Margin="0,0,4,0"/>
<Button Grid.Column="2" Content="Unentschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkUnexcusedCommand}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Entschuldigungen." Classes="emptyhint"
IsVisible="{Binding !OpenExcuses.Count}"/>
</StackPanel>
</Border>
<!-- Fehlzeiten-Warnung (5.2.3) -->
<Border Grid.Column="{Binding AttendanceCard.Column}" Grid.Row="{Binding AttendanceCard.Row}"
IsVisible="{Binding AttendanceCard.EffectiveIsVisible}" Margin="{Binding AttendanceCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FEHLZEITEN-WARNUNG" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding AttendanceWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AttendanceWarningItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<Button Grid.Column="0" Content="{Binding StudentName}" FontSize="13"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentAttendanceCommand}"
CommandParameter="{Binding}"/>
<TextBlock Grid.Column="1" Foreground="Red" FontSize="12" VerticalAlignment="Center">
<Run Text="{Binding AbsenceRatePercent}"/>
<Run Text=" %"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Fehlzeiten über dem Schwellenwert." Classes="emptyhint"
IsVisible="{Binding !AttendanceWarnings.Count}"/>
</StackPanel>
</Border>
<!-- Klausurwochen (Nutzer-Feedback): eigene Klausurlast über alle Kurse hinweg --> <!-- Klausurwochen (Nutzer-Feedback): eigene Klausurlast über alle Kurse hinweg -->
<Border Grid.Column="{Binding ExamLoadCard.Column}" Grid.Row="{Binding ExamLoadCard.Row}" <Border Grid.Column="{Binding ExamLoadCard.Column}" Grid.Row="{Binding ExamLoadCard.Row}"
IsVisible="{Binding ExamLoadCard.EffectiveIsVisible}" Margin="{Binding ExamLoadCard.Margin}" VerticalAlignment="Top" IsVisible="{Binding ExamLoadCard.EffectiveIsVisible}" Margin="{Binding ExamLoadCard.Margin}" VerticalAlignment="Top"
@@ -454,63 +413,6 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Unterrichtszeit nacherfassen (Nutzer-Feedback) -->
<Border Grid.Column="{Binding MissingTeachingTimeCard.Column}" Grid.Row="{Binding MissingTeachingTimeCard.Row}"
IsVisible="{Binding MissingTeachingTimeCard.EffectiveIsVisible}" Margin="{Binding MissingTeachingTimeCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNTERRICHTSZEIT NACHERFASSEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:MissingTeachingTimeItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Erfassen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).AddMissingTeachingTimeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fehlende Unterrichtszeit-Erfassung." Classes="emptyhint"
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
</StackPanel>
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="{Binding SupportCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FÖRDERPLAN-WIEDERVORLAGE" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding SupportPlanReviews}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupportPlanDueItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<Button Content="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentSupportPlanCommand}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding Title}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ReviewDateDisplay}" FontSize="12"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fälligen Überprüfungen." Classes="emptyhint"
IsVisible="{Binding !SupportPlanReviews.Count}"/>
</StackPanel>
</Border>
<!-- Anstehende Termine (9.3) --> <!-- Anstehende Termine (9.3) -->
<Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}" <Border Grid.Column="{Binding UpcomingCard.Column}" Grid.Row="{Binding UpcomingCard.Row}"
IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="{Binding UpcomingCard.Margin}" VerticalAlignment="Top" IsVisible="{Binding UpcomingCard.EffectiveIsVisible}" Margin="{Binding UpcomingCard.Margin}" VerticalAlignment="Top"
@@ -548,100 +450,101 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Offene Korrekturen (9.4) --> <!-- Handlungsbedarf: zusammengefasste Karte für die frueheren sieben Kacheln
<Border Grid.Column="{Binding CorrectionsCard.Column}" Grid.Row="{Binding CorrectionsCard.Row}" (Entschuldigungen, Fehlzeiten, Foerderplan, Korrekturen, ungeplante Stunden,
IsVisible="{Binding CorrectionsCard.EffectiveIsVisible}" Margin="{Binding CorrectionsCard.Margin}" VerticalAlignment="Top" Auffaelligkeiten, Unterrichtszeit-Nacherfassung) — siehe AttentionItem.cs.
Filter-Chips ersetzen die vorherige Sichtbarkeit je Einzelkachel. x:Name traegt den
Weg zum DashboardViewModel durch zwei ItemsControl-Verschachtelungen (Gruppe -> Item)
hindurch, ohne $parent[ItemsControl] mit Ancestor-Level zaehlen zu muessen. -->
<Border Grid.Column="{Binding AttentionCard.Column}" Grid.Row="{Binding AttentionCard.Row}"
IsVisible="{Binding AttentionCard.EffectiveIsVisible}" Margin="{Binding AttentionCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel Spacing="10">
<TextBlock Text="OFFENE KORREKTUREN" FontSize="11" FontWeight="Bold" <TextBlock Text="HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenCorrections}"> <ItemsControl ItemsSource="{Binding AttentionFilters}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel Orientation="Horizontal" ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CorrectionProgressItem"> <DataTemplate x:DataType="vm:AttentionFilterOption">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5" <ToggleButton Content="{Binding Label}" IsChecked="{Binding IsActive}"
FontSize="11" Padding="8,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl x:Name="AttentionGroupsList" ItemsSource="{Binding Attention}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AttentionGroup">
<StackPanel Margin="0,6,0,0" Spacing="4">
<TextBlock FontSize="11" FontWeight="SemiBold" Opacity="0.7">
<Run Text="{Binding Header}"/>
<Run Text=" ("/>
<Run Text="{Binding Count}"/>
<Run Text=")"/>
</TextBlock>
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AttentionItem">
<StackPanel Margin="0,4">
<Grid ColumnDefinitions="4,*">
<Border Grid.Column="0" Classes="severitybar"
Classes.high="{Binding IsHighSeverity}"
Classes.medium="{Binding IsMediumSeverity}" Margin="0,0,9,0"/>
<!-- Bewusst immer als Button (statt je nach IsClickable ein anderes Root-Element):
ein einheitliches Template für alle sieben Arten. Bei Excuse/MissingTeachingTime
ist Navigate null, der Klick auf die Zeile selbst ist dann folgenlos — die
eigentliche Aktion liegt dort in Actions (Entschuldigt/Erfassen) darunter. -->
<Button Grid.Column="1" Background="Transparent" BorderThickness="0" Padding="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenCorrectionCommand}" Command="{Binding #AttentionGroupsList.((vm:DashboardViewModel)DataContext).OpenAttentionItemCommand}"
CommandParameter="{Binding}"> CommandParameter="{Binding}">
<StackPanel Spacing="3"> <StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"/> <TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.6"/>
</Grid>
<TextBlock Text="{Binding GroupName}" FontSize="11" Opacity="0.6"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Percent}" Height="6"/>
<TextBlock Text="{Binding ProgressDisplay}" FontSize="10" Opacity="0.65"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Korrekturen." Classes="emptyhint"
IsVisible="{Binding !OpenCorrections.Count}"/>
</StackPanel>
</Border>
<!-- Ungeplante Stunden -->
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
IsVisible="{Binding UnplannedCard.EffectiveIsVisible}" Margin="{Binding UnplannedCard.Margin}" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNGEPLANTE STUNDEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding UnplannedLessons}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UnplannedLessonItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUnplannedLessonCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Display}" FontSize="13"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding TrailingText}"
Classes="attentionTrailing" Classes.warning="{Binding IsWarningTrailing}"
VerticalAlignment="Center" Margin="8,0,0,0"
IsVisible="{Binding HasTrailingText}"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12" <TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Opacity="0.6"/> VerticalAlignment="Center" Margin="8,0,0,0"
Classes.overdue="{Binding IsOverdue}"
IsVisible="{Binding HasDate}"/>
</Grid> </Grid>
</Button> <TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.6"
</DataTemplate> IsVisible="{Binding HasSubtitle}"/>
</ItemsControl.ItemTemplate> <ProgressBar Minimum="0" Maximum="100" Value="{Binding ProgressPercent}"
</ItemsControl> Height="6" IsVisible="{Binding HasProgress}"/>
<TextBlock Text="Keine ungeplanten Stunden." Classes="emptyhint" <TextBlock Text="{Binding ProgressLabel}" FontSize="10" Opacity="0.65"
IsVisible="{Binding !UnplannedLessons.Count}"/> IsVisible="{Binding HasProgress}"/>
</StackPanel> </StackPanel>
</Border> </Button>
</Grid>
<!-- Auffälligkeiten (9.5) --> <ItemsControl ItemsSource="{Binding Actions}" Margin="13,4,0,0"
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}" IsVisible="{Binding HasActions}">
IsVisible="{Binding AlertsCard.EffectiveIsVisible}" Margin="{Binding AlertsCard.Margin}" VerticalAlignment="Top" <ItemsControl.ItemsPanel>
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" <ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="4"/></ItemsPanelTemplate>
CornerRadius="8" Padding="16"> </ItemsControl.ItemsPanel>
<StackPanel>
<TextBlock Text="AUFFÄLLIGKEITEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding Alerts}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DashboardAlertItem"> <DataTemplate x:DataType="vm:AttentionAction">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,4" <Button Content="{Binding Label}" FontSize="11" Padding="7,3"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" Command="{Binding Command}" CommandParameter="{Binding Parameter}"/>
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenAlertCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,*">
<Border Background="{Binding SeverityColor}" CornerRadius="2" Margin="0,0,9,0"/>
<StackPanel Grid.Column="1">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding KindLabel}" FontSize="10" Opacity="0.6"/>
</Grid>
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.65"/>
</StackPanel>
</Grid>
</Button>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine Auffälligkeiten erkannt." Classes="emptyhint" </StackPanel>
IsVisible="{Binding !Alerts.Count}"/> </DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Punkte." Classes="emptyhint"
IsVisible="{Binding !Attention.Count}"/>
</StackPanel> </StackPanel>
</Border> </Border>
+37
View File
@@ -3228,6 +3228,43 @@ tatsächlichen Lesson oder einem an diesem Tag aktiven Stundenplan-Slot zuerst a
einem Akzentpunkt markiert; innerhalb dieser Gruppe sowie für den Rest gilt alphabetische Sortierung. einem Akzentpunkt markiert; innerhalb dieser Gruppe sowie für den Rest gilt alphabetische Sortierung.
Ferien, Feiertage und vollständig ausgefallene Stunden werden dabei berücksichtigt. Ferien, Feiertage und vollständig ausgefallene Stunden werden dabei berücksichtigt.
**Nachtrag Kachelraster-Bugfix (September 2026):** `ApplyCardLayout()` zählte bislang `IsVisible`
statt `EffectiveIsVisible` für die Zeilen-/Spaltenzuordnung — eine eingeschaltete, aber gerade leere
`HideWhenEmpty`-Kachel (im Alltag der Normalfall, da meist mehrere Hinweiskacheln leer sind) belegte
dadurch weiterhin einen Rasterplatz und riss eine Lücke ins zweispaltige Grid. Zusätzlich hing der
Grid-Margin jeder Kachel fest im XAML an einer Spalte (links/rechts), obwohl Spalte und Zeile erst
zur Laufzeit aus Sichtbarkeit und Reihenfolge berechnet werden — beim Aus-/Einblenden einer Kachel
wanderten die Nachbarn in die andere Spalte, der Rinnstein saß dann auf der falschen Seite.
`DashboardCardOption.Margin` leitet den Wert jetzt aus `Column` ab, das XAML bindet darauf statt
fixer Werte, und `UpdateDashboardSummary()` ruft `ApplyCardLayout()` erst auf, nachdem alle
`IsEmpty`-Werte für den aktuellen Ladevorgang feststehen.
**Nachtrag Handlungsbedarf-Zusammenfassung (September 2026, Nutzer-Feedback):** Die sieben Kacheln,
die tatsächlich "wo muss ich reagieren" beantworten — Offene Entschuldigungen (9), Fehlzeiten-
Warnung (5.2.3), Förderplan-Wiedervorlage (5.3.2), Offene Korrekturen (9.4), Ungeplante Stunden
(9.9), Auffälligkeiten (9.5), Unterrichtszeit nacherfassen — sind zu einer Karte "Handlungsbedarf"
mit Filter-Chips zusammengefasst. Neues gemeinsames Modell `AttentionItem`/`AttentionGroup`/
`AttentionAction` ([AttentionItem.cs](LehrerApp.Desktop/ViewModels/AttentionItem.cs)) trägt nur
Anzeigedaten; die Beschaffung bleibt unverändert in den jeweiligen `DashboardViewModel.LoadXxx`-
Methoden, die am Ende ein `AttentionItem` statt ihrer eigenen Item-Klasse erzeugen.
`DashboardViewModel.RebuildAttention()` gruppiert nach Art (feste Reihenfolge, keine
kachel-übergreifende Neusortierung nach Dringlichkeit — das hätte die je Art bewusst gewählte
Sortierung, z.B. Fehlzeiten nach Quote absteigend, wieder zerstört) und wendet die Filter-Chips an,
ohne die Repos erneut abzufragen. `SupportPlanDueItem`, `CorrectionProgressItem`,
`UnplannedLessonItem` und `DashboardAlertItem` entfallen (nur dashboard-intern verwendet);
`OpenExcuseItem` und `AttendanceWarningItem` bleiben bestehen, da `GroupOverviewViewModel`
(Kurs-Dashboard) sie weiterhin direkt nutzt. Fünf Navigations-Commands (`OpenStudentAttendance`,
`OpenStudentSupportPlan`, `OpenCorrection`, `OpenUnplannedLesson`, `OpenAlert`) wurden durch ein
einziges `OpenAttentionItemCommand` ersetzt, das das im `AttentionItem` mitgegebene `Navigate`-
Delegate aufruft. **Bewusste Verhaltensänderung:** die "Auffälligkeiten"-Kachel zeigte bisher
Fehlzeiten-Überschreitungen zusätzlich zu Notenabfall/Versetzungsgefährdung — in der
zusammengefassten Karte wäre das dieselbe Zahl doppelt (einmal als eigene Gruppe "Fehlzeiten-
Warnung", einmal als "Auffälligkeit"), was vor dem Merge durch zwei getrennte Kacheln nicht auffiel;
`LoadAlerts` erzeugt diese Kopie deshalb nicht mehr. Die Filter-Chips sind bewusst nur
Sitzungszustand (keine Persistenz in `dashboardsettings.json`, das dortige Format ist eine flache
Kachel-Liste ohne Platz für Sub-Filter je Art). "Klausurwochen" bleibt weiterhin eine eigene Kachel
(Wochen-Aggregat, kein Einzelvorgang zum Abhaken/Anklicken).
--- ---
## 10. Sync & Server ## 10. Sync & Server