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 ──── /// /// 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". /// 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 OpenExcuses { get; } = []; public bool HasOpenExcuses => OpenExcuses.Count > 0; /// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") — /// siehe . public ObservableCollection 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? 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(); 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 /// 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>(); 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(); 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)); } }