Klassenlehrer-Tab: vier Feature-Ideen umgesetzt (ohne Elterngesprächs-PDF)

Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach
Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im
Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und
eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile
eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel
verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend.

ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias
$parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) —
stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als
PDF bewusst zurückgestellt, Konzept noch nicht geschärft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:57:09 +02:00
co-authored by Claude Sonnet 5
parent 3f8dab4abd
commit 3f484ce3c8
6 changed files with 537 additions and 7 deletions
@@ -26,6 +26,33 @@ public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject
}
}
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
{
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
.GroupBy(e => e.CategoryName!)
.Select(g =>
{
var topStudents = g.GroupBy(e => e.StudentDisplayName)
.Select(sg => (Name: sg.Key, Count: sg.Count()))
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
.Take(topStudentsPerCategory)
.Select(sg => $"{sg.Count}× {sg.Name}");
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
})
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
.ToList();
}
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
@@ -107,6 +134,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
@@ -118,6 +146,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter;
@@ -132,6 +161,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
StudentFilter = "";
Entries.Clear();
AbsenceEntries.Clear();
CategoryAggregates.Clear();
Status = "Zeitraum wählen und laden.";
NotifyListState();
}
@@ -170,7 +200,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); NotifyListState();
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
try
{
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
@@ -187,6 +217,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
}
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
var absences = absencesTask.Result.Where(MatchesStudentFilter);
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
@@ -216,5 +247,6 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
{
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(HasCategoryAggregates));
}
}