using System.Collections.Concurrent; 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; public partial class WebUntisAbsenceRow : ObservableObject { public required string StudentName { get; init; } public required DateOnly Date { get; init; } public required string TimeLabel { get; init; } public required string UntisStatus { get; init; } public required string LocalStatus { get; init; } public required AttendanceStatus TargetStatus { get; init; } public required Guid StudentId { get; init; } public required Guid? SessionId { get; init; } public string? Reason { get; init; } public string DateLabel => Date.ToString("dd.MM.yyyy"); public bool CanApply => SessionId is not null; [ObservableProperty] private bool _selected; } public partial class WebUntisAbsenceComparisonViewModel : ObservableObject { private readonly LearningGroup _group; private readonly WebUntisIntegrationService _untis; private readonly IStudentRepository _students; private readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _participation; 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; public WebUntisAbsenceComparisonViewModel(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 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()); var loaded = new ConcurrentBag<(Student Student, UntisStudentAbsenceDto Absence)>(); var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student))) .Where(x => x.Key is not null).ToList(); await Parallel.ForEachAsync(linked, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, token) => { var report = await _untis.GetAbsencesAsync(item.Key!.Value, start, end, token); foreach (var absence in report.Absences) loaded.Add((item.Student, absence)); }); foreach (var item in loaded.OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName)) { if (!TryDate(item.Absence.Date, out var date)) continue; localSessions.TryGetValue(date, out var session); var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id); var target = MapStatus(item.Absence.ExcuseStatus); Rows.Add(new WebUntisAbsenceRow { StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date, TimeLabel = $"{Time(item.Absence.StartTime)}–{Time(item.Absence.EndTime)}", UntisStatus = DisplayUntisStatus(item.Absence), LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"), TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason, Selected = session is not null && entry?.Attendance != target, }); } var withoutKey = courseStudents.Count - linked.Count; Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar." + (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : ""); } 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).ToList(); foreach (var row in selected) { var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId) ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId }; entry.Attendance = row.TargetStatus; entry.UpdatedAt = DateTime.UtcNow; _participation.Save(entry); } Status = $"{selected.Count} Anwesenheitsstatus übernommen."; foreach (var row in selected) row.Selected = false; } 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; } private static AttendanceStatus MapStatus(string? value) { var text = value?.Trim().ToLowerInvariant() ?? ""; if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt")) return AttendanceStatus.Unexcused; if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused; return AttendanceStatus.ExcusePending; } private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) => string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } text ? text : "offen"; private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); private static string Time(int value) => $"{value / 100:00}:{value % 100:00}"; }