using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.ClassTeacher; public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, string? AbsenceTooltip, bool HasRecentClassRegisterEntry) { public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; } public bool HasClassRegisterToday { get; init; } public bool IsLate => TodayAbsence is { } a && (a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason)); public bool IsUnexcused => TodayAbsence?.Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true; public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry; public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 : HasRecentClassRegisterEntry ? 3 : 4; public string Initials { get { var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries); return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0]))); } } public string StatusText { get { if (TodayAbsence is null) return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit"; var core = IsLate ? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet" : TodayAbsence.CountsAsFullDay ? "Ganzer Fehltag" : $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}"; return IsUnexcused ? $"{core} · Unentschuldigt" : core; } } public string StatusColor => IsUnexcused ? "#FF5A67" : HasAbsenceToday ? "#F59E0B" : HasRecentClassRegisterEntry ? "#4C8DFF" : "#73C45A"; public string StatusBarColor => StatusColor; public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" : HasRecentClassRegisterEntry ? "Eintrag diese Woche" : ""; private static bool IsLateReason(string reason) => reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); public static IReadOnlyList Build( IReadOnlyList students, IReadOnlyList todayAbsences, IReadOnlyList recentClassRegisterEntries, DateOnly? today = null) { var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) .GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First()); var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName)) .ToDictionary(g => g.Key, g => g.First()); var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)) .ToDictionary(g => g.Key, g => g.ToList()); return students.Select(student => { var nameKey = UntisNameMatching.NameKey(student.DisplayName); var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null) ?? absenceByName.GetValueOrDefault(nameKey); var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? []; return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null, absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", registerEntries.Count > 0) { TodayAbsence = absence, HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate), }; }) .OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList(); } private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); } public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, int LateCount, double AlertBarWidth, double UnexcusedBarWidth, double LateBarWidth); public sealed record ClassTeacherPatternNotice(string StudentName, string Message, string Color); public partial class ClassTeacherOverviewViewModel : ObservableObject { private readonly WebUntisSettingsService _settings; private readonly UntisReportCacheService _cache; public ClassTeacherDetailsViewModel DetailsTab { get; } public ObservableCollection Roster { get; } = []; public ObservableCollection PrimaryRoster { get; } = []; public ObservableCollection SecondaryRoster { get; } = []; public ObservableCollection TrendDays { get; } = []; public ObservableCollection PatternNotices { get; } = []; [ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private int _activeTabIndex; [ObservableProperty] private string _status = ""; [ObservableProperty] private bool _busy; [ObservableProperty] private string _searchText = ""; [ObservableProperty] private int _selectedRosterFilter; [ObservableProperty] private string _primarySectionTitle = "Heute auffällig"; [ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen"; [ObservableProperty] private int _studentCount; [ObservableProperty] private int _todayAlertCount; [ObservableProperty] private int _todayUnexcusedCount; [ObservableProperty] private int _recentClassRegisterCount; [ObservableProperty] private int _presentCount; [ObservableProperty] private int _lateCount; [ObservableProperty] private int _excusedAbsenceCount; [ObservableProperty] private int _unexcusedAbsenceCount; [ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert"; public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HasPrimaryRoster => PrimaryRoster.Count > 0; public bool HasSecondaryRoster => SecondaryRoster.Count > 0; public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0; public bool HasPatternNotices => PatternNotices.Count > 0; public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool AllFilterSelected => SelectedRosterFilter == 2; public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount); public int LatePercent => Percent(LateCount); public int PresentPercent => Percent(PresentCount); public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount); public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount); public Func? OnNavigateToSettings { get; set; } public Func? OnNavigateToWorkload { get; set; } public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab) { _settings = settings; _cache = cache; DetailsTab = detailsTab; } partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSelectedRosterFilterChanged(int value) { OnPropertyChanged(nameof(AlertsFilterSelected)); OnPropertyChanged(nameof(ClassRegisterFilterSelected)); OnPropertyChanged(nameof(AllFilterSelected)); ApplyRosterFilter(); } [RelayCommand] private async Task Load() { HomeroomClassName = _settings.HomeroomClassName; ActiveTabIndex = 0; Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); if (!HomeroomClassConfigured) { Status = "Noch keine Klasse ausgewählt."; NotifyRosterState(); return; } var className = HomeroomClassName!; DetailsTab.Initialize(className); Busy = true; NotifyRosterState(); try { var today = DateOnly.FromDateTime(DateTime.Today); var start = today.AddDays(-6); var studentsTask = _cache.GetStudentRosterAsync(className); var absencesTask = _cache.GetAbsencesAsync(className, start, today); var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, start, today); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask); var absenceDays = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); var todayAbsences = absenceDays.Where(a => a.Date == today).ToList(); foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, classRegisterTask.Result, today)) Roster.Add(row); StudentCount = Roster.Count; TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused); LateCount = Roster.Count(r => r.IsLate); PresentCount = Roster.Count(r => !r.HasAbsenceToday); ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused); UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused); RecentClassRegisterCount = classRegisterTask.Result.Count; BuildTrend(absenceDays, start, today); BuildPatternNotices(absenceDays); LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; ApplyRosterFilter(); NotifySummary(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); } finally { Busy = false; NotifyRosterState(); } } [RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0; [RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1; [RelayCommand] private void ShowAll() => SelectedRosterFilter = 2; [RelayCommand] private void OpenClassRegister() { DetailsTab.StudentFilter = ""; ActiveTabIndex = 1; DetailsTab.LoadCommand.Execute(null); } [RelayCommand] private void OpenAbsences() { DetailsTab.StudentFilter = ""; ActiveTabIndex = 2; DetailsTab.LoadCommand.Execute(null); } [RelayCommand] private void ShowDetailsForStudent(ClassTeacherRosterRow? row) { if (row is null) return; DetailsTab.StudentFilter = row.StudentName; ActiveTabIndex = row.HasAbsenceToday ? 2 : 1; DetailsTab.LoadCommand.Execute(null); } [RelayCommand] private async Task GoToSettings() { if (OnNavigateToSettings is not null) await OnNavigateToSettings(); } [RelayCommand] private async Task GoToWorkload() { if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); } private void ApplyRosterFilter() { PrimaryRoster.Clear(); SecondaryRoster.Clear(); var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) || r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase)); if (SelectedRosterFilter == 0) { PrimarySectionTitle = "Heute auffällig"; foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row); } else if (SelectedRosterFilter == 1) { PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage"; foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row); } else { PrimarySectionTitle = "Heute auffällig"; SecondarySectionTitle = "Weitere Schüler*innen"; foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row); foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row); } NotifyRosterState(); } private void BuildTrend(IReadOnlyList absenceDays, DateOnly start, DateOnly end) { var counts = Enumerable.Range(0, 7).Select(offset => start.AddDays(offset)).Select(date => { var rows = absenceDays.Where(r => r.Date == date).ToList(); return (Date: date, Alerts: rows.Count, Unexcused: rows.Count(r => r.Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase))), Late: rows.Count(r => r.TotalAbsentPeriods == 0 || r.AbsenceReasons.Any(a => a.Contains("verspät", StringComparison.OrdinalIgnoreCase)))); }).ToList(); var max = Math.Max(1, counts.Max(c => c.Alerts)); foreach (var day in counts) TrendDays.Add(new ClassTeacherTrendDay(day.Date == end ? "Heute" : day.Date.ToString("ddd"), day.Alerts, day.Unexcused, day.Late, 112d * day.Alerts / max, 112d * day.Unexcused / max, 112d * day.Late / max)); } private void BuildPatternNotices(IReadOnlyList absenceDays) { foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName))) { var rows = group.ToList(); var displayName = Roster.FirstOrDefault(row => UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName; var lateDays = rows.Count(row => row.IsLate); var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate); if (unexcusedDays >= 2) PatternNotices.Add(new ClassTeacherPatternNotice(displayName, $"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", "#FF5A67")); else if (lateDays >= 2) PatternNotices.Add(new ClassTeacherPatternNotice(displayName, $"{lateDays}-mal verspätet in 7 Tagen", "#F59E0B")); } OnPropertyChanged(nameof(HasPatternNotices)); } private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount); private void NotifySummary() { OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent)); OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent)); OnPropertyChanged(nameof(UnexcusedAbsencePercent)); } private void NotifyRosterState() { OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster)); OnPropertyChanged(nameof(HasNoFilterResults)); } }