using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Students; 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])); } } /// Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am /// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig, /// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben /// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen /// berechnet, kein zusätzlicher WebUntis-Abruf. public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel) { public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel) ? $"{Count}×" : $"{Count}× — {TopStudentsLabel}"; public static IReadOnlyList Build( IReadOnlyList entries, int topStudentsPerCategory = 2) => entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName)) .GroupBy(e => e.CategoryName!) .Select(g => { var topStudents = g.GroupBy(e => e.StudentDisplayName) .Select(sg => (Name: sg.Key, Count: sg.Count())) .OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name) .Take(topStudentsPerCategory) .Select(sg => $"{sg.Count}× {sg.Name}"); return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents)); }) .OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName) .ToList(); } /// 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); } public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework } /// Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht. public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label, string ColorHex, ClassTeacherCalendarEventKind Kind) { public string Tooltip => $"{StudentName}: {Label}"; } /// Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter. public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList Events) { public bool IsPlaceholder => Date is null; public string DayLabel => Date?.ToString("dd.") ?? ""; public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? ""; public bool HasEvents => Events.Count > 0; } /// /// 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 readonly IDocumentationRepository _documentation; private readonly IStudentRepository _students; private readonly ClassTeacherCasesViewModel _cases; private string _className = ""; private DateOnly _loadedStart; private DateOnly _loadedEnd; /// Schüler*innen der Klasse, per Namensabgleich () den lokalen /// -Datensätzen zugeordnet — Grundlage für die "Eigene Dokumentation"-Ansicht, /// die es (anders als der WebUntis-Klassenbuchbericht) nur lokal gibt. Nach jedem /// neu aufgebaut. private List<(Guid StudentId, string DisplayName)> _rosterMatches = []; public ObservableCollection Entries { get; } = []; public ObservableCollection AbsenceEntries { get; } = []; public ObservableCollection CategoryAggregates { get; } = []; public ObservableCollection OwnDocumentationEntries { get; } = []; public ObservableCollection CalendarDays { get; } = []; /// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden). [ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry; [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; [ObservableProperty] private bool _showMonthlyCalendar; [ObservableProperty] private bool _includeHomeworkInCalendar; [ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1); /// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation. [ObservableProperty] private bool _showOwnDocumentation; [ObservableProperty] private int _ownDocumentationCount; [ObservableProperty] private int _ownDocumentationFollowUpCount; [ObservableProperty] private int _ownDocumentationCriticalCount; public Func, Documentation?, Task>? OnEditOwnDocumentation { get; set; } public Func>? OnConfirmDeleteOwnDocumentation { get; set; } /// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen /// oder einen neuen Titel eingeben. Liefert null bei Abbruch. public Func>? OnPickVorgangForPin { get; set; } public bool HasEntries => Entries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0; public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries; public bool HasCategoryAggregates => CategoryAggregates.Count > 0; public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0; /// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen () /// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup. public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation; public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus); public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter) ? "Alle Schüler*innen" : StudentFilter; public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation, IStudentRepository students, ClassTeacherCasesViewModel cases) { _cache = cache; _documentation = documentation; _students = students; _cases = cases; } public void Initialize(string className) { _className = className; StudentFilter = ""; Entries.Clear(); AbsenceEntries.Clear(); CalendarDays.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear(); _rosterMatches = []; Status = "Zeitraum wählen und laden."; NotifyListState(); } partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel)); partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates)); partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty)); partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar(); partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel)); 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); [RelayCommand] private void ShowUntisRegister() => ShowOwnDocumentation = false; [RelayCommand] private void ShowOwnDocs() => ShowOwnDocumentation = true; /// 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 void ShowAbsenceList() => ShowMonthlyCalendar = false; [RelayCommand] private async Task ShowCalendar() { ShowMonthlyCalendar = true; await LoadCalendarMonth(forceRefresh: false); } [RelayCommand] private async Task PreviousCalendarMonth() { CalendarMonth = CalendarMonth.AddMonths(-1); await LoadCalendarMonth(forceRefresh: false); } [RelayCommand] private async Task NextCalendarMonth() { CalendarMonth = CalendarMonth.AddMonths(1); await LoadCalendarMonth(forceRefresh: false); } private Task LoadCalendarMonth(bool forceRefresh) { StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue)); var end = CalendarMonth.AddMonths(1).AddDays(-1); EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue)); return LoadInternal(forceRefresh); } [RelayCommand] private async Task ClearStudentFilter() { StudentFilter = ""; await LoadInternal(forceRefresh: false); } private async Task LoadInternal(bool forceRefresh) { 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; } if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; } Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear(); NotifyListState(); try { var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh); var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh); var rosterTask = _cache.GetStudentRosterAsync(_className); await Task.WhenAll(classRegisterTask, absencesTask, rosterTask); 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)); } foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row); var absences = absencesTask.Result.Where(MatchesStudentFilter); foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) AbsenceEntries.Add(row); BuildCalendar(); var localStudents = _students.GetAll(); _rosterMatches = rosterTask.Result .Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents))) .Where(x => x.Student is not null) .Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName)) .DistinctBy(x => x.StudentId) .ToList(); _loadedStart = start; _loadedEnd = end; LoadOwnDocumentationEntries(); Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " + $"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum."; NotifyListState(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; NotifyListState(); } } private void BuildCalendar() { CalendarDays.Clear(); foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar)) CalendarDays.Add(day); } /// Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative /// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage. public static IReadOnlyList BuildCalendarDays(DateOnly month, IReadOnlyList absences, IReadOnlyList registerEntries, bool includeHomework) { var first = new DateOnly(month.Year, month.Month, 1); var last = first.AddMonths(1).AddDays(-1); var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7)); var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7); var result = new List(); for (var date = gridStart; date <= gridEnd; date = date.AddDays(1)) { if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue; if (date.Month != first.Month) { result.Add(new ClassTeacherCalendarDay(null, [])); continue; } var events = new List(); foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName)) { var (code, label, color, kind) = absence.IsUnexcused ? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused) : absence.IsLate ? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late) : absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase) ? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused) : ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent); events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind)); } foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName)) { var isHomework = ContainsHomework(entry); if (isHomework && !includeHomework) continue; if (!entry.IsDangerStatus && !isHomework) continue; events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName, isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag", isHomework ? "#6A1B9A" : "#8E24AA", isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister)); } result.Add(new ClassTeacherCalendarDay(date, events)); } return result; } private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) => entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true || entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true; /// Baut aus und dem zuletzt /// geladenen Zeitraum neu auf — separat von , damit Anlegen/Bearbeiten/ /// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche /// Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren /// — analog zu . private void LoadOwnDocumentationEntries() { OwnDocumentationEntries.Clear(); var entries = FilterOwnDocumentation(_documentation.GetAll(), _rosterMatches, _loadedStart, _loadedEnd, StudentFilter); foreach (var d in entries) { var name = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName; OwnDocumentationEntries.Add(new DocumentationItem(d, name)); } OwnDocumentationCount = OwnDocumentationEntries.Count; OwnDocumentationFollowUpCount = entries.Count(d => d.IsDraft); OwnDocumentationCriticalCount = entries.Count(d => d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase))); OnPropertyChanged(nameof(HasOwnDocumentationEntries)); } /// Eigene Dokumentation zu Schüler*innen der Klasse im gewählten Zeitraum: nur Einträge /// (nicht gelöschter) Schüler*innen, die sich per Namensabgleich der WebUntis-Klasse zuordnen /// ließen (, siehe ), /// mit demselben Schülerfilter wie die WebUntis-Klassenbuchzeilen. Entwürfe ("Nacharbeiten") /// zuerst, danach neueste zuerst. public static List FilterOwnDocumentation(IReadOnlyList all, IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, DateOnly start, DateOnly end, string studentFilter) { var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet(); return all .Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId) && d.Date >= start && d.Date <= end) .Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter)) .OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date) .ToList(); } private static bool MatchesOwnDocStudentFilter(Documentation d, IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter) { if (string.IsNullOrWhiteSpace(studentFilter)) return true; var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName; return name is not null && UntisNameMatching.NamesMatch(name, studentFilter); } [RelayCommand] private async Task AddOwnDocumentation() { if (OnEditOwnDocumentation is null) return; var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList(); var result = await OnEditOwnDocumentation(options, null); if (result is null) return; _documentation.Save(result); LoadOwnDocumentationEntries(); } [RelayCommand] private async Task EditOwnDocumentation(DocumentationItem? item) { if (item is null || OnEditOwnDocumentation is null) return; var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList(); var result = await OnEditOwnDocumentation(options, item.Model); if (result is null) return; _documentation.Save(result); LoadOwnDocumentationEntries(); } [RelayCommand] private async Task DeleteOwnDocumentation(DocumentationItem? item) { if (item is null) return; if (OnConfirmDeleteOwnDocumentation is not null && !await OnConfirmDeleteOwnDocumentation(item)) return; _documentation.Delete(item.Model.Id); LoadOwnDocumentationEntries(); } /// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese /// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases). public List OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName); /// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang /// im "Vorgänge"-Tab () an — die Zeile selbst hat keine /// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry). [RelayCommand] private async Task PinToVorgang(ClassTeacherClassRegisterRow? row) { if (row is null || OnPickVorgangForPin is null) return; var choice = await OnPickVorgangForPin(row); if (choice is null) return; var entry = BuildClassRegisterSnapshot(row); if (choice.ExistingVorgangId is { } vorgangId) await _cases.PinClassRegisterEntryAsync(vorgangId, entry); else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle)) { var student = _cases.RosterStudentOptions .FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName)); if (student is null) return; await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry); } } /// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff /// testbare Umwandlung. public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new() { Date = row.Date, StudentName = row.StudentName, Subject = row.Subject, TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName, CategoryGroup = row.CategoryGroup, Text = row.Text, }; // 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)); OnPropertyChanged(nameof(ShowAbsenceListEmpty)); OnPropertyChanged(nameof(HasCategoryAggregates)); OnPropertyChanged(nameof(ShowCategoryAggregates)); OnPropertyChanged(nameof(HasOwnDocumentationEntries)); OnPropertyChanged(nameof(UntisCriticalCount)); } }