using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.Students; /// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - /// kann manuell per Auswahlliste gesetzt werden, wenn der Name nicht eindeutig auf ein/e Schüler*in /// passt (siehe WebUntisLessonAbsenceComparisonViewModel für dasselbe Muster). public partial class WebUntisDocumentationRow : ObservableObject { public required string ClassName { get; init; } public required DateOnly Date { get; init; } public required string UntisStudentName { get; init; } public string? Subject { get; init; } public string? CategoryName { get; init; } public string? CategoryGroup { get; init; } public string? Text { get; init; } public required IReadOnlyList Candidates { get; init; } public string DateLabel => Date.ToString("dd.MM.yyyy"); public bool CanApply => AssignedStudent is not null; [ObservableProperty] private Student? _assignedStudent; /// Titel/Text eines lokalen Eintrags, der am selben Tag für diese/n Schüler*in schon /// existiert - nur nach Datum+Schüler*in erkannt, nicht nach Wortlaut (der unterscheidet sich oft /// von der WebUntis-Kategorie). Deshalb Anzeige zum Vergleichen statt automatischem Ausblenden. [ObservableProperty] private string? _existingLocalEntry; [ObservableProperty] private bool _selected; } /// Lokaler Dokumentationseintrag im geladenen Zeitraum ohne passenden WebUntis-Eintrag - kann /// nicht automatisch nach WebUntis geschrieben werden (bewusst keine schreibenden Aufrufe gegen die /// undokumentierte API), deshalb nur als Kopiervorlage für die manuelle Nacherfassung dort. public sealed record LocalOnlyDocumentationRow( string StudentName, DateOnly Date, string? GroupName, string Title, string Content) { public string DateLabel => Date.ToString("dd.MM.yyyy"); public string ClipboardText => $"{DateLabel} – {StudentName}" + (GroupName is null ? "" : $" ({GroupName})") + $"\n{Title}\n{Content}"; } /// Abgleich der WebUntis-Klassenbucheinträge (eigene, siehe /// ) gegen die lokale /// - Dashboard-weit statt pro Lerngruppe, weil der WebUntis-"-alle-"- /// Bericht ebenfalls klassenübergreifend ist (siehe TODO.md, Nachtrag zu 4.3). public partial class WebUntisDocumentationComparisonViewModel : ObservableObject { private readonly WebUntisIntegrationService _untis; private readonly IStudentRepository _students; private readonly IGroupRepository _groups; private readonly IDocumentationRepository _documentation; private readonly SchoolYearService _schoolYears; public ObservableCollection Rows { get; } = []; public ObservableCollection LocalOnlyRows { get; } = []; [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7); [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden."; [ObservableProperty] private bool _busy; public WebUntisDocumentationComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students, IGroupRepository groups, IDocumentationRepository documentation, SchoolYearService schoolYears) { _untis = untis; _students = students; _groups = groups; _documentation = documentation; _schoolYears = schoolYears; } [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(); LocalOnlyRows.Clear(); try { var ownStudents = _students.GetAll(); var globalIndex = BuildNameIndex(ownStudents); // Zusätzlich pro Klasse (aktuelles Schuljahr) indiziert: löst den Fall "gleicher Name in // verschiedenen Klassen" auf, den ein rein globaler Namensabgleich nicht unterscheiden könnte. var currentSchoolYear = _schoolYears.CurrentSchoolYear(); var classIndexes = _groups.GetAll() .Where(g => g.Type == GroupType.Class && g.SchoolYear == currentSchoolYear) .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()), StringComparer.OrdinalIgnoreCase); var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end); var localDocs = _documentation.GetAll().Where(d => d.Date >= start && d.Date <= end).ToList(); var ordered = entries .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null)) .Where(x => x.Date is not null) .OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName); // Nur nach Schüler*in+Datum erkannt, nicht nach Wortlaut: ein lokaler Eintrag von vor dieser // Funktion (oder frei formuliert) hat selten denselben Titel wie die WebUntis-Kategorie. List LocalDocsFor(Guid studentId, DateOnly date) => localDocs.Where(d => d.StudentId == studentId && d.Date == date).ToList(); foreach (var (entry, date) in ordered) { var nameKey = NameKey(entry.StudentName); var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex) ? classIndex.GetValueOrDefault(nameKey) : null) ?? globalIndex.GetValueOrDefault(nameKey); var existing = match is null ? [] : LocalDocsFor(match.Id, date!.Value); Rows.Add(new WebUntisDocumentationRow { ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName, Subject = entry.Subject, CategoryName = entry.CategoryName, CategoryGroup = entry.CategoryGroup, Text = entry.Text, Candidates = ownStudents, AssignedStudent = match, ExistingLocalEntry = existing.Count == 0 ? null : string.Join(" | ", existing.Select(d => $"{d.Title}: {d.Content}")), Selected = match is not null && existing.Count == 0, }); } foreach (var doc in localDocs) { if (doc.ExcludeFromWebUntisSync) continue; var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == doc.StudentId && r.Date == doc.Date); if (coveredByReport) continue; var student = ownStudents.FirstOrDefault(s => s.Id == doc.StudentId); if (student is null) continue; LocalOnlyRows.Add(new LocalOnlyDocumentationRow(student.FullName, doc.Date, doc.GroupId is { } groupId ? _groups.GetById(groupId)?.Name : null, doc.Title, doc.Content)); } var unresolved = Rows.Count(x => x.AssignedStudent is null); var possibleDuplicates = Rows.Count(x => x.ExistingLocalEntry is not null); Status = $"{Rows.Count} WebUntis-Einträge erhalten, {possibleDuplicates} mit lokalem Eintrag am " + "selben Tag (bitte vergleichen)" + (unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") + $". {LocalOnlyRows.Count} lokale Einträge ohne WebUntis-Gegenstück."; } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; } } [RelayCommand] private void Apply() { var selected = Rows.Where(x => x.Selected && x.CanApply).ToList(); foreach (var row in selected) { _documentation.Save(new Documentation { StudentId = row.AssignedStudent!.Id, Date = row.Date, Type = DocumentationType.Incident, Title = row.CategoryName ?? "WebUntis-Klassenbucheintrag", Content = row.Text ?? "", Tags = row.CategoryGroup is { Length: > 0 } group ? [group] : [], }); } foreach (var row in selected) { row.ExistingLocalEntry = $"{row.CategoryName}: {row.Text}"; row.Selected = false; } Status = $"{selected.Count} Einträge aus WebUntis übernommen."; } private static string NameKey(string value) => value.Trim().ToLowerInvariant(); // Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge, // deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig. private static Dictionary BuildNameIndex(IReadOnlyList candidates) => candidates .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); private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); }