diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 7756094..c20d5e4 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -126,6 +126,51 @@ public sealed class ClassTeacherViewModelsTests Assert.True(ben.HasRecentClassRegisterEntry); } + [Fact] + public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden() + { + var students = new[] { Student(1001, "Ada Müller") }; + var todayAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20, + ["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false), + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [], + new DateOnly(2026, 8, 26))); + + Assert.True(row.IsLate); + Assert.True(row.IsUnexcused); + Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText); + Assert.Equal("#FF5A67", row.StatusColor); + } + + [Fact] + public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern() + { + var students = new[] + { + Student(1001, "Zora Unauffällig"), + Student(1002, "Ada Fehlzeit"), + Student(1003, "Ben Klassenbuch"), + }; + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90, + ["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), + }; + var register = new[] + { + new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test", + "Fehlende HA", "Negativ", "Hausaufgaben fehlen"), + }; + + var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26)); + + Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"], + rows.Select(r => r.StudentName)); + } + private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index 7dc1fc1..31df481 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -12,6 +12,15 @@ public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text) { public string DateLabel => Date.ToString("dd.MM.yyyy"); + public string StudentDisplayName => DisplayName(StudentName); + public string CategoryColor => CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true + ? "#FF5A67" : "#4C8DFF"; + + private static string DisplayName(string value) + { + var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0])); + } } /// Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst - @@ -25,6 +34,14 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName string? Note, string? ExcuseNote, bool CountsAsFullDay) { public string DateLabel => Date.ToString("dd.MM.yyyy"); + public string StudentDisplayName + { + get + { + var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length < 2 ? StudentName : string.Join(" ", parts.Skip(1).Append(parts[0])); + } + } public string SubjectsLabel => string.Join(", ", Subjects); public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order()); public string StatusLabel => string.Join(", ", Statuses); @@ -33,6 +50,17 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName /// dezente Hervorhebung statt einer weiteren Text-/Icon-Spalte. public string RowBackground => CountsAsFullDay ? "#1FFFA000" : "Transparent"; public string? FullDayTooltip => CountsAsFullDay ? "Ganzer Fehltag" : null; + public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r => + r.Contains("verspät", StringComparison.OrdinalIgnoreCase)); + public bool IsUnexcused => Statuses.Any(s => + s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)); + public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" : + CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}"; + public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" : + Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel; + public string StatusColor => IsUnexcused ? "#FF5A67" : IsLate ? "#F59E0B" : "#73C45A"; + public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote } + .Where(value => !string.IsNullOrWhiteSpace(value))); public static IReadOnlyList GroupByStudentAndDay( IEnumerable entries) => entries @@ -78,12 +106,18 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public ObservableCollection Entries { get; } = []; public ObservableCollection AbsenceEntries { get; } = []; - [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7); + [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6); [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private string _status = "Zeitraum wählen und laden."; [ObservableProperty] private bool _busy; /// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen. [ObservableProperty] private string _studentFilter = ""; + [ObservableProperty] private int _quickRangeIndex = 1; + + public bool HasEntries => Entries.Count > 0; + public bool HasAbsenceEntries => AbsenceEntries.Count > 0; + public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter) + ? "Alle Schüler*innen" : StudentFilter; public ClassTeacherDetailsViewModel(UntisReportCacheService cache) { @@ -93,9 +127,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public void Initialize(string className) { _className = className; + StudentFilter = ""; Entries.Clear(); AbsenceEntries.Clear(); Status = "Zeitraum wählen und laden."; + NotifyListState(); + } + + partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel)); + + partial void OnQuickRangeIndexChanged(int value) + { + var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 }; + EndDate = DateTimeOffset.Now; + StartDate = DateTimeOffset.Now.AddDays(-days); } [RelayCommand] @@ -106,6 +151,16 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject [RelayCommand] private Task Refresh() => LoadInternal(forceRefresh: true); + [RelayCommand] + private Task ApplyFilter() => LoadInternal(forceRefresh: false); + + [RelayCommand] + private async Task ClearStudentFilter() + { + StudentFilter = ""; + await LoadInternal(forceRefresh: false); + } + private async Task LoadInternal(bool forceRefresh) { var start = DateOnly.FromDateTime(StartDate.LocalDateTime); @@ -113,7 +168,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(); + Busy = true; Entries.Clear(); AbsenceEntries.Clear(); NotifyListState(); try { var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh); @@ -137,9 +192,10 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " + $"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum."; + NotifyListState(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; } - finally { Busy = false; } + finally { Busy = false; NotifyListState(); } } // WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem @@ -153,4 +209,10 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); + + private void NotifyListState() + { + OnPropertyChanged(nameof(HasEntries)); + OnPropertyChanged(nameof(HasAbsenceEntries)); + } } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 6dd6e3f..6873416 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -6,62 +6,91 @@ using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.ClassTeacher; -/// Eine Zeile der Klassenlehrer-Übersicht ("auf einen Blick", Nutzer-Feedback) - pro -/// Schüler*in ein Ampel-Symbol für "heute laut WebUntis keine gemeldete Fehlzeit" und ein Badge, -/// falls in den letzten Tagen ein Klassenbucheintrag einer anderen Lehrkraft angelegt wurde. -/// Bewusst kein Bezug zu eigenem Unterricht/eigener Anwesenheitserfassung - genau der Fall, den es -/// abzudecken gilt, ist der Tag ohne eigenen Unterricht mit der Klasse. public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, string? AbsenceTooltip, bool HasRecentClassRegisterEntry) { - public string StatusIcon => HasAbsenceToday ? "⚠" : "✓"; + 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); - /// Führt die WebUntis-Schülerliste der Klasse mit den heutigen Fehlzeiten und den - /// jüngsten Klassenbucheinträgen zusammen. Zuordnung primär über ExternKey (beide - /// stammen aus WebUntis, gleiche Kennung), Fallback auf reihenfolge-unabhängigen Namensabgleich - /// () für den Klassenbuch-Bericht, der keinen ExternKey liefert - /// (siehe UntisForeignClassRegisterEventDto) — WebUntis liefert Namen dort in anderer - /// Reihenfolge als im Schülerreport. public static IReadOnlyList Build( IReadOnlyList students, IReadOnlyList todayAbsences, - IReadOnlyList recentClassRegisterEntries) + IReadOnlyList recentClassRegisterEntries, + DateOnly? today = null) { - var absenceByKey = todayAbsences - .Where(a => a.ExternKey is not null) - .GroupBy(a => a.ExternKey!.Value) + 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 absenceByName = todayAbsences - .GroupBy(a => UntisNameMatching.NameKey(a.StudentName)) - .ToDictionary(g => g.Key, g => g.First()); - var recentNames = recentClassRegisterEntries - .Select(e => UntisNameMatching.NameKey(e.StudentName)) - .ToHashSet(); + var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)) + .ToDictionary(g => g.Key, g => g.ToList()); - return students - .Select(s => + return students.Select(student => { - var nameKey = UntisNameMatching.NameKey(s.DisplayName); - var absence = (s.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null) + var nameKey = UntisNameMatching.NameKey(student.DisplayName); + var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null) ?? absenceByName.GetValueOrDefault(nameKey); - return new ClassTeacherRosterRow(s.DisplayName, s.ExternKey, absence is not null, + 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}", - recentNames.Contains(nameKey)); + registerEntries.Count > 0) + { + TodayAbsence = absence, + HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate), + }; }) - .OrderBy(r => r.StudentName) - .ToList(); + .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); } -/// -/// Top-Level-Bereich "Klassenlehrer" (eigener Sidebar-Eintrag, siehe MainWindowViewModel) - bewusst -/// nicht an eine LearningGroup gehängt, da man Klassenlehrer für eine ganze Klasse ist, unabhängig -/// vom eigenen Unterricht (Nutzer-Feedback zur ursprünglichen Umsetzung als Gruppen-Tab). Die -/// Klasse selbst kommt aus den WebUntis-Einstellungen (). -/// Zwei Ebenen: Übersicht (dieses ViewModel, kompaktes Roster) und Details -/// (, die bisherigen Rohdaten-Listen) - ein Klick auf -/// eine Roster-Zeile springt gefiltert in die Details. -/// +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; @@ -69,57 +98,132 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject 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); - /// Vom Code-Behind/MainWindowViewModel gesetzt: springt in die WebUntis-Einstellungen, wenn noch - /// keine Klasse gewählt ist. public Func? OnNavigateToSettings { get; set; } + public Func? OnNavigateToWorkload { get; set; } public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab) { - _settings = settings; _cache = cache; DetailsTab = 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(); + 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, today, today); - var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, today.AddDays(-7), today); + var absencesTask = _cache.GetAbsencesAsync(className, start, today); + var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, start, today); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask); - var todayAbsences = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); + 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)) - Roster.Add(row); - Status = $"{Roster.Count} Schüler*innen · Stand heute."; + 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; } - finally { Busy = false; } + 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] @@ -127,13 +231,87 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject { if (row is null) return; DetailsTab.StudentFilter = row.StudentName; - ActiveTabIndex = 1; + ActiveTabIndex = row.HasAbsenceToday ? 2 : 1; DetailsTab.LoadCommand.Execute(null); } - [RelayCommand] - private async Task GoToSettings() + [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() { - if (OnNavigateToSettings is not null) await OnNavigateToSettings(); + 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)); } } diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 2b57169..f0c1952 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -150,6 +150,7 @@ public partial class MainWindowViewModel : ObservableObject { var vm = _services.GetRequiredService(); vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; }; + vm.OnNavigateToWorkload = () => { NavigateToWorkload(); return Task.CompletedTask; }; vm.LoadCommand.Execute(null); return vm; } diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml new file mode 100644 index 0000000..51d2ac0 --- /dev/null +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml @@ -0,0 +1,72 @@ + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +