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 LehrerApp.Desktop.ViewModels.Workload; 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. Dieselbe Konstante wie DashboardViewModel.AttendanceMinSampleSize /// (Hauptdashboard-Fehlzeiten-Warnung, 5.2.3) — dort dasselbe Problem, mittlerweile ebenfalls /// gefixt. private const int AttendanceMinSampleSize = 8; private readonly ILessonRepository _lessons; private readonly IExamRepository _exams; private readonly IExamResultRepository _examResults; private readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _entries; private readonly IStudentRepository _students; private readonly IDocumentationRepository _documentation; private readonly IWorkTaskRepository _tasks; private readonly IGroupRepository _groups; private readonly AttendanceBalanceService _attendanceBalance; private readonly GradingService _grading; 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 bool _hasYearComparison; [ObservableProperty] private string _yearComparisonLabel = ""; [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; [ObservableProperty] private string _missingHomeworkSessionLabel = ""; public ObservableCollection MissingHomeworkStudents { get; } = []; public bool HasMissingHomework => MissingHomeworkStudents.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; } /// Anders als OnNavigateToTab kein Tab innerhalb dieser Detailansicht, sondern ein Sprung in /// den eigenständigen Top-Level-Aufgabenbereich (MainWindowViewModel.NavigateToWorkload) — von /// hier aus nicht direkt aufrufbar (keine ViewModel-zu-ViewModel-Referenz, siehe /// GroupDetailView.axaml.cs, gleiches Muster wie LessonViewerDialog/TeachingModeWindow). public Action? OnNavigateToWorkload { 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); [RelayCommand] private void NavigateToWorkload() => OnNavigateToWorkload?.Invoke(); // ── Anstehende Aufgaben für diese Klasse (Nutzer-Feedback: pädagogische Erinnerungen/Aufgaben // sollen "gleichberechtigt" auch im Kurs-Dashboard stehen, nicht nur im Hauptdashboard) ─────── private const int GroupTasksMaxCount = 5; public ObservableCollection GroupTasks { get; } = []; public bool HasGroupTasks => GroupTasks.Count > 0; /// Öffnet den Aufgaben-Dialog mit dieser Gruppe vorbelegt (Nutzer-Feedback: direkter /// Anlege-Einstieg aus dem Kurs-Dashboard, statt erst über "Zu den Aufgaben" springen zu /// müssen). Gleiches View-Code-Behind-Delegate-Muster wie OnNavigateToWorkload. public Func>? OnAddGroupTask { get; set; } [RelayCommand] private async Task AddGroupTask() { if (OnAddGroupTask is null) return; var result = await OnAddGroupTask(_groupId); if (result is null) return; _tasks.Save(result); LoadGroupTasks(DateOnly.FromDateTime(DateTime.Today)); } public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams, IExamResultRepository examResults, IParticipationSessionRepository sessions, IParticipationRepository entries, IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups, AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear) { _lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions; _entries = entries; _students = students; _documentation = documentation; _tasks = tasks; _groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _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); LoadYearComparison(); LoadParticipationHint(today); LoadOpenHomeworkCheck(today); LoadOpenExcuses(today); LoadDraftDocumentationCount(); LoadAttendanceNotices(today); LoadMissingHomework(); LoadGroupTasks(today); } private void LoadNextLesson(DateOnly today) { var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays)) .Where(l => l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled)) .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}"; } // ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ────────────────── // // Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe // hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds // hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn // im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich, // auch wenn eine Verknüpfung besteht. private void LoadYearComparison() { var group = _groups.GetById(_groupId); var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null; if (previous is null) { HasYearComparison = false; return; } var current = ComputeExamAverage(_groupId); YearComparisonLabel = current is null ? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur." : $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " + $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)"; HasYearComparison = true; } private (double Average, int Count)? ComputeExamAverage(Guid groupId) { var grades = _exams.GetByGroup(groupId) .SelectMany(e => _examResults.GetByExam(e.Id)) .Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade)) .Select(r => (Grade: r.Grade!, Weight: 1.0)) .ToList(); return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count); } /// 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)) .Where(l => l.Status != LessonStatus.Cancelled) .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)); } /// Dieselbe Mindeststichprobe wie DashboardViewModel.LoadAttendanceWarnings /// (AttendanceMinSampleSize) — erst ab genug erfassten Terminen wird eine hohe Quote gemeldet, /// 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)) continue; var balance = _attendanceBalance.Calculate(entries, from, to); if (balance.TotalChecked >= AttendanceMinSampleSize && 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)); } /// Schüler mit nicht gemachter Hausaufgabe aus der letzten Mitarbeitssitzung /// (ParticipationEntry.HomeworkMissing, gepflegt über HomeworkDisplay.CountsAsMissing) — /// bewusst nur die letzte Sitzung, nicht die gesamte Historie, analog zur /// Hausaufgaben-Kontrolle in . private void LoadMissingHomework() { MissingHomeworkStudents.Clear(); var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault(); if (last is not null) { MissingHomeworkSessionLabel = $"Aus der Sitzung vom {last.Date:dd.MM.yyyy}:"; foreach (var entry in _entries.GetBySession(last.Id).Where(e => e.HomeworkMissing)) { var student = _students.GetById(entry.StudentId); if (student is not null) MissingHomeworkStudents.Add(new MissingHomeworkItem(student.FullName, HomeworkDisplay.Label(entry.Homework))); } } OnPropertyChanged(nameof(HasMissingHomework)); } private void LoadGroupTasks(DateOnly today) { GroupTasks.Clear(); foreach (var t in _tasks.GetByGroup(_groupId) .Where(t => t.Status != WorkTaskStatus.Done) .OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(GroupTasksMaxCount)) GroupTasks.Add(new GroupTaskItem(t.Title, t.Kind == TaskKind.Reminder, t.DueDate?.ToString("dd.MM.yyyy") ?? "", t.DueDate is { } d && d < today, TaskPriorityDisplay.ColorHex(t.Priority), t.Priority == TaskPriority.High)); OnPropertyChanged(nameof(HasGroupTasks)); } } public sealed class MissingHomeworkItem(string studentName, string statusLabel) { public string StudentName { get; } = studentName; public string StatusLabel { get; } = statusLabel; } public sealed class GroupTaskItem(string title, bool isReminder, string dueDateDisplay, bool isOverdue, string priorityColorHex, bool isHighPriority) { public string Title { get; } = title; public bool IsReminder { get; } = isReminder; public string DueDateDisplay { get; } = dueDateDisplay; public bool IsOverdue { get; } = isOverdue; public string PriorityColorHex { get; } = priorityColorHex; public bool IsHighPriority { get; } = isHighPriority; }