using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.ClassTeacher; /// Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat. /// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein /// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind. public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text) { public string DateLabel => Date.ToString("dd.MM.yyyy"); public string StudentDisplayName => DisplayName(StudentName); public ClassTeacherStatusKind CategoryKind => CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true ? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info; public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger; public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info; 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 - /// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere /// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt /// Rohdaten). Die Rohzeilen () bleiben trotzdem erreichbar, /// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md). public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey, int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList Subjects, IReadOnlyList PeriodNumbers, IReadOnlyList Statuses, IReadOnlyList AbsenceReasons, 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); public string ReasonLabel => string.Join("; ", AbsenceReasons); 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 ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger : IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok; public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger; public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning; public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote } .Where(value => !string.IsNullOrWhiteSpace(value))); public static IReadOnlyList GroupByStudentAndDay( IEnumerable entries) => entries .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null)) .Where(x => x.Date is not null) .GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey)) .Select(g => new ClassAbsenceDaySummaryRow( g.Key.Date, g.Key.StudentName, g.Key.ExternKey, g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes), Distinct(g.Select(x => x.Entry.Subject)), g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(), Distinct(g.Select(x => x.Entry.Status)), Distinct(g.Select(x => x.Entry.AbsenceReason)), g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)), g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)), g.Any(x => x.Entry.CountsAsFullDay))) .OrderByDescending(r => r.Date).ThenBy(r => r.StudentName) .ToList(); private static List Distinct(IEnumerable values) => values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList(); private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); } /// /// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu /// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der /// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der /// Klasse über alle Fächer — beide über (lokal gecacht statt /// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in /// lokale Daten. Bewusst unabhängig von jeder — /// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse /// kommt aus (letztlich aus den WebUntis-Einstellungen), /// nicht aus einer Gruppen-ID. /// public partial class ClassTeacherDetailsViewModel : ObservableObject { private readonly UntisReportCacheService _cache; private string _className = ""; public ObservableCollection Entries { get; } = []; public ObservableCollection AbsenceEntries { get; } = []; [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) { _cache = cache; } 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] private Task Load() => LoadInternal(forceRefresh: false); /// Umgeht bewusst die Stunden-Sperre von — für den Fall, /// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat. [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); var end = DateOnly.FromDateTime(EndDate.LocalDateTime); 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(); NotifyListState(); try { var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh); var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh); await Task.WhenAll(classRegisterTask, absencesTask); var ordered = classRegisterTask.Result .Where(MatchesStudentFilter) .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null)) .Where(x => x.Date is not null) .OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName); foreach (var (entry, date) in ordered) { Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName, entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text)); } var absences = absencesTask.Result.Where(MatchesStudentFilter); foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) AbsenceEntries.Add(row); Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " + $"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum."; NotifyListState(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; NotifyListState(); } } // WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem // StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein // exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen. private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) => string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter); private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) => string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter); 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)); } }