using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Importing; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.Groups; /// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend /// weggefiltert zu werden) - kann manuell per Auswahlliste gesetzt /// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen. public partial class WebUntisLessonAbsenceRow : ObservableObject { public required string UntisStudentName { get; init; } public required DateOnly Date { get; init; } public required string TimeLabel { get; init; } public required string UntisStatus { get; init; } public required AttendanceStatus TargetStatus { get; init; } public required IReadOnlyList Candidates { get; init; } internal Action? OnAssignmentChanged { get; init; } public string? Reason { get; init; } public string DateLabel => Date.ToString("dd.MM.yyyy"); public bool CanApply => SessionId is not null; [ObservableProperty] private Student? _assignedStudent; [ObservableProperty] private string _localStatus = "ohne Zuordnung"; [ObservableProperty] private Guid? _sessionId; [ObservableProperty] private bool _selected; partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this); } /// Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht (). /// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte /// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt. public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject { private readonly LearningGroup _group; private readonly WebUntisIntegrationService _untis; private readonly IStudentRepository _students; private readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _participation; private IReadOnlyList _loadedStudents = []; private IReadOnlyDictionary _loadedSessions = new Dictionary(); public ObservableCollection Rows { get; } = []; [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2); [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; [ObservableProperty] private bool _busy; [ObservableProperty] private bool _markUnknownAsPresent; public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, IStudentRepository students, IParticipationSessionRepository sessions, IParticipationRepository participation) { _group = group; _untis = untis; _students = students; _sessions = sessions; _participation = participation; } [RelayCommand] private async Task Load() { var lessonId = _group.WebUntisLessonId; if (lessonId is null) { Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " + "(Gruppe bearbeiten)."; return; } 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; } Busy = true; Rows.Clear(); try { var courseStudents = _students.GetByGroup(_group.Id); var localSessions = _sessions.GetByGroup(_group.Id) .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) .ToDictionary(x => x.Key, x => x.First()); _loadedStudents = courseStudents; _loadedSessions = localSessions; // Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt // per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb // der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten. var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student))) .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student); var byName = courseStudents .SelectMany(student => new[] { NameKey($"{student.LastName} {student.FirstName}"), NameKey($"{student.FirstName} {student.LastName}"), }.Select(key => (Key: key, Student: student))) .GroupBy(x => x.Key) .Where(group => group.Select(x => x.Student).Distinct().Count() == 1) .ToDictionary(group => group.Key, group => group.First().Student); var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end); var ordered = absences .Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null)) .Where(x => x.Date is not null) .OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName); void ResolveLocalMatch(WebUntisLessonAbsenceRow row) { if (row.AssignedStudent is not { } student) { row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false; return; } localSessions.TryGetValue(row.Date, out var session); var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id); row.SessionId = session?.Id; row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance); row.Selected = session is not null && entry?.Attendance != row.TargetStatus; } foreach (var (absence, date) in ordered) { var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent) ? byKeyStudent : byName.GetValueOrDefault(NameKey(absence.StudentName)); var row = new WebUntisLessonAbsenceRow { UntisStudentName = absence.StudentName, Date = date!.Value, TimeLabel = TimeLabel(absence.StartTime, absence.EndTime), UntisStatus = DisplayUntisStatus(absence), TargetStatus = MapStatus(absence), Reason = absence.Reason, Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch, }; Rows.Add(row); row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected } var unresolved = Rows.Count(x => x.AssignedStudent is null); Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" + (unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") + $". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar."; } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; } } [RelayCommand] private void Apply() { var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList(); foreach (var row in selected) { var studentId = row.AssignedStudent!.Id; var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId) ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId }; entry.Attendance = row.TargetStatus; entry.UpdatedAt = DateTime.UtcNow; _participation.Save(entry); } var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0; Status = $"{selected.Count} Anwesenheitsstatus übernommen." + (MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : ""); foreach (var row in selected) row.Selected = false; } // "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile // gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen // Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt" // und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur // mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist. private int FillUnknownAsPresent() { var identified = Rows.Where(x => x.AssignedStudent is not null) .Select(x => (x.Date, StudentId: x.AssignedStudent!.Id)) .ToHashSet(); var filled = 0; foreach (var session in _loadedSessions.Values) foreach (var student in _loadedStudents) { if (identified.Contains((session.Date, student.Id))) continue; var entry = _participation.GetBySessionAndStudent(session.Id, student.Id); if (entry?.Attendance is not null) continue; entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id }; entry.Attendance = AttendanceStatus.Present; entry.UpdatedAt = DateTime.UtcNow; _participation.Save(entry); filled++; } return filled; } private static int? StudentKey(Student student) { student.ExternalIds ??= []; return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value) && int.TryParse(value, out var key) ? key : null; } // Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht // dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen // werden beim Aufbau von `byName` registriert; hier wird nur normalisiert. private static string NameKey(string value) => value.Trim().ToLowerInvariant(); private const int FullLessonMinutes = 45; // Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die // (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in // Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist // wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der // Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung // oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist // laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt. private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) { if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass; if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late; if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending; if (absence.ExternKey is null) return AttendanceStatus.ExcusePending; return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused; } private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) => absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true; // Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung // (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden. private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) => string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason } .Where(x => !string.IsNullOrWhiteSpace(x))); private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); private static string TimeLabel(int? start, int? end) => start is null || end is null ? "" : $"{Time(start.Value)}–{Time(end.Value)}"; private static string Time(int value) => $"{value / 100:00}:{value % 100:00}"; }