Kurs-Übersicht gefüllt: nächste Termine, Mitarbeitshinweis, offene Kontrollen/Dokumentationen, auffällige Fehlzeiten

Der bislang leere "Übersicht"-Tab je Kurs zeigt jetzt einen kompakten
"Was steht an"-Überblick aus bereits vorhandenen Daten mit Klick-Durchsprung
in den jeweiligen Tab: nächste Stunde/Klausur, Hinweis auf die letzte
Mitarbeitssitzung, offene Hausaufgaben-Kontrolle, offene Entschuldigungen,
offene Dokumentations-Entwürfe und auffällige Fehlzeiten.

Die Fehlzeiten-Karte ist bewusst kein 1:1-Abbild der Dashboard-Warnung: erst
ab einer Mindeststichprobe von 8 erfassten Terminen im Schuljahr gemeldet,
damit ein einzelner Fehltag zu Schuljahresbeginn nicht sofort als auffällig
gilt. Das Hauptdashboard hat dasselbe Problem noch, wird dort separat
nachgezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:39:31 +02:00
co-authored by Claude Sonnet 5
parent f73bd6c9ec
commit 4d2a89b14b
9 changed files with 683 additions and 8 deletions
@@ -0,0 +1,239 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Tab: Übersicht — kompakte "Was steht an"-Zusammenfassung eines Kurses ────
/// <summary>
/// Zieht keine eigenen neuen Daten — jede Karte liest nur, was die anderen Tabs (Planung,
/// Klausuren, Mitarbeit) ohnehin schon verwalten, und verweist per Klick dorthin. Bewusst kein
/// Ersatz für die Detail-Tabs, sondern ein schneller Überblick "was ist als nächstes dran /
/// was wurde vergessen".
/// </summary>
public partial class GroupOverviewViewModel : ObservableObject
{
private const int OpenExcuseMaxAgeDays = 21;
private const int HomeworkCheckLookbackDays = 120;
private const int NextLessonLookaheadDays = 90;
/// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine
/// hohe Fehlquote bewusst NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rein
/// rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dasselbe Problem hat aktuell noch die Fehlzeiten-Warnung im Hauptdashboard
/// (AttendanceBalanceService/AttendanceWarningItem) — bewusst unangetastet, wird dort separat
/// nachgezogen.
private const int AttendanceMinSampleSize = 8;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _schoolYear;
private Guid _groupId;
private string _groupName = "";
[ObservableProperty] private bool _hasNextLesson;
[ObservableProperty] private string _nextLessonLabel = "";
[ObservableProperty] private bool _hasNextExam;
[ObservableProperty] private string _nextExamLabel = "";
[ObservableProperty] private string _participationHintLabel = "";
[ObservableProperty] private bool _participationHintIsStale;
[ObservableProperty] private bool _hasOpenHomeworkCheck;
[ObservableProperty] private string _openHomeworkCheckLabel = "";
[ObservableProperty] private int _draftDocumentationCount;
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public bool HasOpenExcuses => OpenExcuses.Count > 0;
/// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
/// siehe <see cref="AttendanceMinSampleSize"/>.
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
public bool HasAttendanceNotices => AttendanceNotices.Count > 0;
/// Ein Delegate statt einem pro Karte, mit dem Ziel-Tab-Index von GroupDetailView.axaml als
/// Parameter (0 Übersicht, 1 Schüler, 2 Sitzpläne, 3 Mitarbeit, 4 Klausuren, 5 Noten,
/// 6 Planung, 7 Kompetenzen, 8 Dokumentation).
public Action<int>? OnNavigateToTab { get; set; }
[RelayCommand] private void NavigateToPlanning() => OnNavigateToTab?.Invoke(6);
[RelayCommand] private void NavigateToExams() => OnNavigateToTab?.Invoke(4);
[RelayCommand] private void NavigateToParticipation() => OnNavigateToTab?.Invoke(3);
[RelayCommand] private void NavigateToDocumentation() => OnNavigateToTab?.Invoke(8);
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
IParticipationSessionRepository sessions, IParticipationRepository entries,
IStudentRepository students, IDocumentationRepository documentation,
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
{
_lessons = lessons; _exams = exams; _sessions = sessions;
_entries = entries; _students = students; _documentation = documentation;
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
}
public void Initialize(Guid groupId, string groupName)
{
_groupId = groupId;
_groupName = groupName;
Refresh();
}
public void Refresh()
{
var today = DateOnly.FromDateTime(DateTime.Today);
LoadNextLesson(today);
LoadNextExam(today);
LoadParticipationHint(today);
LoadOpenHomeworkCheck(today);
LoadOpenExcuses(today);
LoadDraftDocumentationCount();
LoadAttendanceNotices(today);
}
private void LoadNextLesson(DateOnly today)
{
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
.Where(l => l.Status == LessonStatus.Planned)
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
.FirstOrDefault();
HasNextLesson = next is not null;
NextLessonLabel = next is null ? ""
: string.IsNullOrWhiteSpace(next.Topic)
? next.Date.ToString("dd.MM.yyyy")
: $"{next.Date:dd.MM.yyyy} — {next.Topic}";
}
private void LoadNextExam(DateOnly today)
{
var next = _exams.GetByGroup(_groupId).Where(e => e.Date >= today).MinBy(e => e.Date);
HasNextExam = next is not null;
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
}
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
private void LoadParticipationHint(DateOnly today)
{
var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault();
if (last is null)
{
ParticipationHintLabel = "Noch keine Mitarbeitssitzung angelegt.";
ParticipationHintIsStale = false;
return;
}
var daysAgo = today.DayNumber - last.Date.DayNumber;
ParticipationHintLabel = daysAgo <= 0
? "Letzte Sitzung: heute."
: $"Letzte Sitzung: {last.Date:dd.MM.yyyy} (vor {daysAgo} Tag(en)).";
ParticipationHintIsStale = daysAgo > 14;
}
/// Dieselbe Erkennung wie das Stundenplan-Badge "Hausaufgabe kontrollieren"
/// (TimetableViewModel.HasUnhandledHomework) — bewusst nur die unmittelbar letzte Lesson,
/// nicht die gesamte Historie.
private void LoadOpenHomeworkCheck(DateOnly today)
{
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault();
var open = previous is not null && !string.IsNullOrWhiteSpace(previous.Homework)
&& !previous.HomeworkChecked && !previous.HomeworkCheckDismissed;
HasOpenHomeworkCheck = open;
OpenHomeworkCheckLabel = open
? $"Aus der Stunde vom {previous!.Date:dd.MM.yyyy}: {previous.Homework}"
: "";
}
private void LoadOpenExcuses(DateOnly today)
{
OpenExcuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>();
foreach (var session in _sessions.GetByGroup(_groupId).Where(s => s.Date >= cutoff && s.Date <= today))
{
foreach (var entry in _entries.GetBySession(session.Id)
.Where(e => e.Attendance == AttendanceStatus.ExcusePending))
{
var student = _students.GetById(entry.StudentId);
if (student is null) continue;
var item = new OpenExcuseItem(session.Id, entry.StudentId, student.FullName, _groupName, session.Date);
item.OnResolve = ResolveExcuse;
items.Add(item);
}
}
foreach (var item in items.OrderBy(i => i.Date))
OpenExcuses.Add(item);
OnPropertyChanged(nameof(HasOpenExcuses));
}
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
{
var entry = _entries.GetBySessionAndStudent(item.SessionId, item.StudentId);
if (entry is null) return;
entry.Attendance = status;
_entries.Save(entry);
OpenExcuses.Remove(item);
OnPropertyChanged(nameof(HasOpenExcuses));
}
/// Entwürfe (5.1.1: schnelle Notiz beim Sitzplan, noch nicht zu einem echten Dokumentations-
/// eintrag ausformuliert) dieser Gruppe — dieselbe Zählung wie das Badge in
/// GroupDocumentationTabViewModel.Load, hier nur ohne die vollständige Entwurfsliste.
private void LoadDraftDocumentationCount()
{
DraftDocumentationCount = _students.GetByGroup(_groupId)
.SelectMany(s => _documentation.GetByStudent(s.Id))
.Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
}
/// Bewusst kein 1:1-Abbild der Dashboard-Fehlzeiten-Warnung: dieselbe Quote
/// (AttendanceBalanceService.WarningThresholdPercent), aber erst ab
/// <see cref="AttendanceMinSampleSize"/> erfassten Terminen, damit ein Fehltag in der ersten
/// Schulwoche nicht sofort als auffällig gilt.
private void LoadAttendanceNotices(DateOnly today)
{
AttendanceNotices.Clear();
var schoolYear = _schoolYear.CurrentSchoolYear(today);
var from = _schoolYear.SchoolYearStart(schoolYear);
var to = _schoolYear.SchoolYearEnd(schoolYear);
var entriesByStudent = new Dictionary<Guid, List<(DateOnly Date, AttendanceStatus? Status)>>();
foreach (var session in _sessions.GetByGroup(_groupId).Where(s => s.Date >= from && s.Date <= to))
{
foreach (var entry in _entries.GetBySession(session.Id))
{
if (!entriesByStudent.TryGetValue(entry.StudentId, out var list))
entriesByStudent[entry.StudentId] = list = [];
list.Add((session.Date, entry.Attendance));
}
}
var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetByGroup(_groupId))
{
if (!entriesByStudent.TryGetValue(student.Id, out var entries) || entries.Count < AttendanceMinSampleSize)
continue;
var balance = _attendanceBalance.Calculate(entries, from, to);
if (balance.ExceedsThreshold)
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
}
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
AttendanceNotices.Add(item);
OnPropertyChanged(nameof(HasAttendanceNotices));
}
}
@@ -208,12 +208,14 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
partial void OnActiveTabIndexChanged(int value)
{
if (value == 0) OverviewTab.Refresh();
if (value == 7) CompetencyOverviewTab.Refresh();
}
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
public GroupOverviewViewModel OverviewTab { get; }
public ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
@@ -233,12 +235,14 @@ public partial class GroupDetailViewModel : ObservableObject
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
OverviewTab = overviewTab;
ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab;
@@ -251,6 +255,7 @@ public partial class GroupDetailViewModel : ObservableObject
ParticipationTab.RefreshCurrentGrid();
};
SeatingPlanTab.OnDocumentationChanged = GroupDocumentationTab.Refresh;
OverviewTab.OnNavigateToTab = idx => ActiveTabIndex = idx;
}
public void LoadGroup(Guid id)
@@ -266,6 +271,7 @@ public partial class GroupDetailViewModel : ObservableObject
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015")}";
LoadStudents();
ReloadExams();
OverviewTab.Initialize(Group.Id, GroupTitle);
ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
PlanningTab.Initialize(Group.Id, IsReadOnly);