using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; namespace LehrerApp.Desktop.ViewModels.Planning; /// /// Zeigt die erkannten regulären WebUntis-Wochenmuster und lässt den Nutzer die vorgeschlagene /// Lerngruppe je Muster bestätigen oder ändern, bevor der laufende Abgleich (UntisSyncService) /// automatisch Vertretungen dafür schreibt (Nutzer-Feedback: die erstmalige Zuordnung ist /// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine /// Bestätigung). Termine ohne Klassenbezug (Aufsichten/Springstunden, Nutzer-Feedback: "Zwei /// Termine sind meine Aufsichten, die nicht zugeordnet werden können") lassen sich stattdessen per /// Checkbox als Aufsicht bestätigen. Zusätzlich informativ: Stundenplan-Einträge, zu denen kein /// WebUntis-Muster mehr passt ("Stundenplan passt nicht mehr"). /// /// Beim erneuten Öffnen werden bereits bestätigte Zuordnungen aus dem Repository vorbefüllt /// (Nutzer-Feedback: "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" — die /// Auswahl wurde zwar gespeichert, beim nächsten Öffnen aber von der frischen Musterkennung /// überschrieben, was wie ein Datenverlust wirkte) und beim Speichern per vorhandener Id /// aktualisiert statt dupliziert (siehe UntisMappingRow.BuildMapping). /// public partial class UntisMappingReviewDialogViewModel : ObservableObject { private readonly UntisSyncService _untisSync; private readonly IUntisSlotMappingRepository _mappings; /// Gruppenname + Fach zur Auswahl (Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das /// Fach dabei, kann ich nicht sicher die richtige Lerngruppe hier auswählen") — dieselbe /// Namenskonflikt-Begründung wie in TimetableSlotDialogViewModel, hier über eine kleine /// Anzeige-Hülle statt eines Label-Strings, da dieser Dialog direkt an LearningGroup-Objekte /// bindet. public List Groups { get; } public ObservableCollection Rows { get; } = []; public ObservableCollection UnmatchedSlotLabels { get; } = []; public bool HasUnmatchedSlots => UnmatchedSlotLabels.Count > 0; public bool HasNoRows => !IsLoading && Rows.Count == 0; [ObservableProperty] private bool _isLoading = true; [ObservableProperty] private string _errorMessage = ""; public bool Result { get; private set; } public UntisMappingReviewDialogViewModel(UntisSyncService untisSync, IUntisSlotMappingRepository mappings, List groups, ISubjectRepository subjects) { _untisSync = untisSync; _mappings = mappings; var subjectNames = subjects.GetAll().ToDictionary(s => s.Id, s => s.Name); Groups = groups.OrderBy(g => g.Name) .Select(g => new UntisGroupOption(g, g.SubjectId is { } sid ? subjectNames.GetValueOrDefault(sid) : null)) .ToList(); } public async Task LoadAsync() { IsLoading = true; ErrorMessage = ""; try { var preview = await _untisSync.FetchAndBuildMatchPreviewAsync(); Rows.Clear(); UnmatchedSlotLabels.Clear(); if (preview is null) { ErrorMessage = "Keine iCal-URL hinterlegt."; return; } // Bei mehreren Mappings für denselben Slot (z.B. Altdaten vor dem Bugfix) gewinnt das // zuletzt angelegte - gleiche Regel wie UntisDiffService. var existingByKey = _mappings.GetAll() .GroupBy(m => (m.Weekday, m.StartTime)) .ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First()); foreach (var match in preview.Matches.Matches.OrderBy(m => m.Pattern.Weekday).ThenBy(m => m.Pattern.StartTime)) { existingByKey.TryGetValue((match.Pattern.Weekday, match.Pattern.StartTime), out var existing); Rows.Add(new UntisMappingRow(match, Groups, existing)); } foreach (var slot in preview.Matches.UnmatchedTimetableSlots) UnmatchedSlotLabels.Add($"{WeekdayLabel(slot.Weekday)}, {slot.PeriodNumber}. Stunde"); OnPropertyChanged(nameof(HasUnmatchedSlots)); } catch (Exception ex) { ErrorMessage = $"Abruf fehlgeschlagen: {ex.Message}"; } finally { IsLoading = false; OnPropertyChanged(nameof(HasNoRows)); } } [RelayCommand] private void Save() { var confirmed = Rows.Select(r => r.BuildMapping()).Where(m => m is not null).Select(m => m!); _untisSync.ConfirmMappings(confirmed); Result = true; } private static string WeekdayLabel(DayOfWeek weekday) => weekday switch { DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag", DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag", DayOfWeek.Friday => "Freitag", DayOfWeek.Saturday => "Samstag", DayOfWeek.Sunday => "Sonntag", _ => weekday.ToString(), }; } public partial class UntisMappingRow : ObservableObject { private readonly Guid? _existingMappingId; public UntisSlotMatch Match { get; } public string WeekdayLabel { get; } public string TimeLabel { get; } public string PatternLabel { get; } public bool IsConfident => Match.IsConfident; public bool IsSupervisionCandidate => Match.IsSupervisionCandidate; public bool CanResolve => IsSupervisionCandidate ? Match.AfterPeriod is not null : Match.PeriodNumber is not null; [ObservableProperty] private UntisGroupOption? _selectedGroup; [ObservableProperty] private bool _confirmAsSupervision; public UntisMappingRow(UntisSlotMatch match, List groups, UntisSlotMapping? existing) { Match = match; _existingMappingId = existing?.Id; WeekdayLabel = match.Pattern.Weekday switch { DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi", DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", DayOfWeek.Saturday => "Sa", _ => "So", }; TimeLabel = IsSupervisionCandidate ? $"{match.Pattern.StartTime:HH:mm}–{match.Pattern.EndTime:HH:mm} (Pause nach Stunde {match.AfterPeriod})" : match.CoveredPeriods.Count > 1 ? $"{match.CoveredPeriods[0]}.–{match.CoveredPeriods[^1]}. Stunde ({match.Pattern.StartTime:HH:mm}, Doppelstunde)" : match.PeriodNumber is { } period ? $"{period}. Stunde ({match.Pattern.StartTime:HH:mm})" : $"{match.Pattern.StartTime:HH:mm} (keine passende Stunde im Stundenraster)"; PatternLabel = IsSupervisionCandidate ? "Aufsicht / Springstunde (kein Klassenbezug)" : match.Pattern.ClassTokens.Count == 0 ? (match.Pattern.Summary ?? "(ohne Fach)") : $"{match.Pattern.Summary ?? "?"} · {string.Join(", ", match.Pattern.ClassTokens)}"; if (IsSupervisionCandidate) { _confirmAsSupervision = existing is { Confirmed: true, Kind: SubstitutionKind.Supervision }; } else { _selectedGroup = existing is { Confirmed: true, Kind: SubstitutionKind.Lesson, GroupId: { } existingGroupId } ? groups.FirstOrDefault(g => g.Group.Id == existingGroupId) : match.SuggestedGroupId is { } suggestedId ? groups.FirstOrDefault(g => g.Group.Id == suggestedId) : null; } } /// null, wenn diese Zeile nicht (mehr) bestätigt ist — wird beim Speichern übersprungen. internal UntisSlotMapping? BuildMapping() { if (IsSupervisionCandidate) { if (!ConfirmAsSupervision || Match.AfterPeriod is null) return null; return new UntisSlotMapping { Id = _existingMappingId ?? Guid.NewGuid(), Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime, Summary = Match.Pattern.Summary, ClassToken = "", Kind = SubstitutionKind.Supervision, AfterPeriod = Match.AfterPeriod, Confirmed = true, }; } if (SelectedGroup is null || Match.PeriodNumber is null) return null; return new UntisSlotMapping { Id = _existingMappingId ?? Guid.NewGuid(), Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime, Summary = Match.Pattern.Summary, ClassToken = string.Join(";", Match.Pattern.ClassTokens), Kind = SubstitutionKind.Lesson, GroupId = SelectedGroup.Group.Id, PeriodNumber = Match.PeriodNumber, CoveredPeriods = Match.CoveredPeriods, Confirmed = true, }; } } public sealed class UntisGroupOption(LearningGroup group, string? subjectName) { public LearningGroup Group { get; } = group; public string DisplayLabel { get; } = string.IsNullOrEmpty(subjectName) ? group.Name : $"{group.Name} ({subjectName})"; }