using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.AiPlanning; 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; // Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe // WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus // denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage // an das KI-Backend personenbezogen leer bleibt. internal int AbsentMinutes { get; init; } internal bool HandledOn { get; init; } internal bool? ExternKeyInParentheses { get; init; } // Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete // TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der // ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die // ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei // GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf // Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel // sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt // private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren // kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen. internal static readonly AttendanceStatus[] SelectableStatuses = [ AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused, AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present, ]; public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray(); [ObservableProperty] private Student? _assignedStudent; [ObservableProperty] private string _localStatus = "ohne Zuordnung"; [ObservableProperty] private Guid? _sessionId; [ObservableProperty] private bool _selected; [ObservableProperty] private string _selectedStatusName = ""; public AttendanceStatus SelectedStatus => SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus); 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 readonly AiPlanningService _ai; private readonly AiSettingsService _aiSettings; 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 _aiSuggestBusy; [ObservableProperty] private bool _markUnknownAsPresent; public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, IStudentRepository students, IParticipationSessionRepository sessions, IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings) { _group = group; _untis = untis; _students = students; _sessions = sessions; _participation = participation; _ai = ai; _aiSettings = aiSettings; } [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 ?? DateTimeOffset.Now).LocalDateTime); var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).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: UntisLessonAbsenceHelper.StudentExternKey(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 targetStatus = MapStatus(absence); var row = new WebUntisLessonAbsenceRow { UntisStudentName = absence.StudentName, Date = date!.Value, TimeLabel = TimeLabel(absence.StartTime, absence.EndTime), UntisStatus = DisplayUntisStatus(absence), TargetStatus = targetStatus, Reason = absence.Reason, AbsentMinutes = absence.AbsentMinutes, HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn), ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses, Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch, SelectedStatusName = AttendanceDisplay.Label(targetStatus), }; 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; } } /// /// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten /// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen /// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse /// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein /// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie /// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte /// übernehmen" bleibt der einzige schreibende Schritt. /// [RelayCommand] private async Task SuggestStatusWithAi() { var token = _aiSettings.GetToken(); if (token is null) { Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden."; return; } var candidates = Rows.Where(x => x.CanApply).ToList(); if (candidates.Count == 0) { Status = "Keine Zeilen mit lokaler Kursstunde geladen."; return; } AiSuggestBusy = true; try { var requestRows = candidates.Select((row, i) => new AiUntisStatusRow { Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes, HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses, CurrentGuess = row.TargetStatus.ToString(), }).ToList(); var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token); var applied = 0; for (var i = 0; i < candidates.Count; i++) { if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue; if (!Enum.TryParse(statusName, out var status)) continue; if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue; candidates[i].SelectedStatusName = AttendanceDisplay.Label(status); applied++; } Status = suggestions.Count == 0 ? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert." : $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen."; } catch (AiBackendException ex) { Status = ex.Message; } finally { AiSuggestBusy = 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.SelectedStatus; 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; } // 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(); // MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit // UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das // aus dem Tritt geraten könnte. private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) => UntisLessonAbsenceHelper.MapStatus(absence); // 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}"; }