From d297f9f109852a294ae2cfd5dfad8f4f26b89777 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Tue, 1 Sep 2026 17:05:13 +0200 Subject: [PATCH] Klassenbuch-Tab zum Dokumentations-Hub ausgebaut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Umschalter zwischen WebUntis-Klassenbuch und eigener Dokumentation statt Merge beider strukturell unterschiedlicher Datensätze, mit Zahlen-Badges je Quelle (kritisch/Nacharbeit hervorgehoben) im Tab und an der "Klassenbuch öffnen"-Aktion der Übersicht. Co-Authored-By: Claude Sonnet 5 --- .../ClassTeacherViewModelsTests.cs | 64 ++++++++ .../ClassTeacherDetailsViewModel.cs | 139 ++++++++++++++++- .../ClassTeacherOverviewViewModel.cs | 33 +++- .../ClassTeacherOverviewView.axaml | 16 ++ .../ClassTeacherRegisterView.axaml | 144 +++++++++++++++++- .../ClassTeacherRegisterView.axaml.cs | 42 +++++ TODO.md | 27 ++++ 7 files changed, 453 insertions(+), 12 deletions(-) diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index a359d63..aad0e64 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -584,4 +584,68 @@ public sealed class ClassTeacherViewModelsTests private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; + + // ── Klassenbuch-Tab als Dokumentations-Hub: eigene Dokumentation neben WebUntis ───────────── + + [Fact] + public void FilterOwnDocumentation_ZeigtNurEintraegeZugeordneterSchuelerImZeitraum() + { + var ada = Guid.NewGuid(); + var fremd = Guid.NewGuid(); + var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller") }; + var docs = new List + { + new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Im Zeitraum" }, + new() { StudentId = ada, Date = new DateOnly(2026, 7, 1), Title = "Vor dem Zeitraum" }, + new() { StudentId = fremd, Date = new DateOnly(2026, 8, 20), Title = "Anderer Schüler" }, + new() { StudentId = ada, Date = new DateOnly(2026, 8, 21), Title = "Gelöscht", IsDeleted = true }, + }; + + var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches, + new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), ""); + + Assert.Equal(["Im Zeitraum"], result.Select(d => d.Title)); + } + + [Fact] + public void FilterOwnDocumentation_SortiertEntwuerfeVorNeuestenZuerst() + { + var id = Guid.NewGuid(); + var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (id, "Ada Müller") }; + var docs = new List + { + new() { StudentId = id, Date = new DateOnly(2026, 8, 25), Title = "Neu, fertig" }, + new() { StudentId = id, Date = new DateOnly(2026, 8, 10), Title = "Alt, Entwurf", IsDraft = true }, + new() { StudentId = id, Date = new DateOnly(2026, 8, 20), Title = "Mittel, fertig" }, + }; + + var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches, + new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), ""); + + Assert.Equal(["Alt, Entwurf", "Neu, fertig", "Mittel, fertig"], result.Select(d => d.Title)); + } + + [Fact] + public void FilterOwnDocumentation_SchuelerfilterErkenntVertauschteReihenfolge() + { + // Derselbe Namensabgleich wie bei den WebUntis-Klassenbuchzeilen (siehe + // RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge) - der Filter kommt aus der + // Übersicht als "Nachname Vorname" oder "Vorname Nachname" je nach Bericht. + var ada = Guid.NewGuid(); + var ben = Guid.NewGuid(); + var rosterMatches = new List<(Guid StudentId, string DisplayName)> + { + (ada, "Ada Müller"), (ben, "Ben Schmidt"), + }; + var docs = new List + { + new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Ada" }, + new() { StudentId = ben, Date = new DateOnly(2026, 8, 20), Title = "Ben" }, + }; + + var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches, + new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "Müller Ada"); + + Assert.Equal(["Ada"], result.Select(d => d.Title)); + } } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index 6dd76d5..8c8e847 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -1,7 +1,10 @@ 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; @@ -130,11 +133,21 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName public partial class ClassTeacherDetailsViewModel : ObservableObject { private readonly UntisReportCacheService _cache; + private readonly IDocumentationRepository _documentation; + private readonly IStudentRepository _students; 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; } = []; [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6); [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; @@ -143,16 +156,32 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject /// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen. [ObservableProperty] private string _studentFilter = ""; [ObservableProperty] private int _quickRangeIndex = 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; } public bool HasEntries => Entries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0; 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 ClassTeacherDetailsViewModel(UntisReportCacheService cache) + public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation, + IStudentRepository students) { _cache = cache; + _documentation = documentation; + _students = students; } public void Initialize(string className) @@ -162,12 +191,16 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject Entries.Clear(); AbsenceEntries.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 OnQuickRangeIndexChanged(int value) { var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 }; @@ -178,6 +211,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject [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] @@ -200,12 +236,15 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject 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(); NotifyListState(); + 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); - await Task.WhenAll(classRegisterTask, absencesTask); + var rosterTask = _cache.GetStudentRosterAsync(_className); + await Task.WhenAll(classRegisterTask, absencesTask, rosterTask); var ordered = classRegisterTask.Result .Where(MatchesStudentFilter) @@ -223,14 +262,103 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) AbsenceEntries.Add(row); + 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 im Zeitraum."; + $"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum."; NotifyListState(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; } finally { Busy = false; NotifyListState(); } } + /// 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(); + } + // 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. @@ -248,5 +376,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject OnPropertyChanged(nameof(HasEntries)); OnPropertyChanged(nameof(HasAbsenceEntries)); OnPropertyChanged(nameof(HasCategoryAggregates)); + OnPropertyChanged(nameof(ShowCategoryAggregates)); + OnPropertyChanged(nameof(HasOwnDocumentationEntries)); + OnPropertyChanged(nameof(UntisCriticalCount)); } } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 67b05f0..933ec38 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -214,6 +214,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject private readonly SchoolYearService _schoolYear; private readonly IWorkTaskRepository _workTasks; private readonly IStudentRepository _students; + private readonly IDocumentationRepository _documentation; private readonly IParticipationRepository _participation; private readonly IParticipationSessionRepository _participationSessions; private readonly AppLogger? _logger; @@ -244,6 +245,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject [ObservableProperty] private int _unexcusedAbsenceCount; [ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert"; [ObservableProperty] private int _openExcuseOverflowCount; + /// Eigene Dokumentation zu Schüler*innen der Klasse mit "Nacharbeiten"-Status bzw. dem + /// "Kritisch"-Tag — Kurzform derselben Zählung wie im Klassenbuch-Tab (siehe + /// ), hier direkt an + /// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte. + [ObservableProperty] private int _ownDocumentationFollowUpCount; + [ObservableProperty] private int _ownDocumentationCriticalCount; public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HasPrimaryRoster => PrimaryRoster.Count > 0; @@ -252,6 +259,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public bool HasPatternNotices => PatternNotices.Count > 0; public bool HasOpenExcuses => OpenExcuses.Count > 0; public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0; + public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0; public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool AllFilterSelected => SelectedRosterFilter == 2; @@ -289,7 +297,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, - IStudentRepository students, IParticipationRepository participation, + IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation, IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab, AppLogger? logger = null) { @@ -299,6 +307,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject _schoolYear = schoolYear; _workTasks = workTasks; _students = students; + _documentation = documentation; _participation = participation; _participationSessions = participationSessions; _logger = logger; @@ -307,6 +316,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow)); + partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts)); + partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts)); partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSelectedRosterFilterChanged(int value) { @@ -372,6 +383,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject BuildPatternNotices(absenceDaysYear, sevenDayStart); BuildWeekdayPatternNotices(absenceDaysYear); BuildAttendanceParticipationNotices(); + BuildOwnDocumentationCounts(); BuildOpenExcuses(absenceDaysYear, today); LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; @@ -687,6 +699,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject OnPropertyChanged(nameof(HasPatternNotices)); } + /// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf + /// die letzten 7 Tage begrenzt wie : ein seit Wochen + /// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl + /// verschwinden, nur weil er nicht mehr taufrisch ist. Selbe Namensabgleich-Logik wie + /// . + private void BuildOwnDocumentationCounts() + { + var students = _students.GetAll(); + var matchedIds = Roster + .Select(r => MatchStudent(r.StudentName, students)) + .Where(s => s is not null) + .Select(s => s!.Id) + .ToHashSet(); + var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList(); + OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft); + OwnDocumentationCriticalCount = docs.Count(d => + d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase))); + } + /// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt FirstName/LastName /// statt , weil dessen "Nachname, Vorname"-Format mit Komma den /// leerzeichenbasierten Wortabgleich in verfälschen würde diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index 0548e54..ef44a6d 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -464,6 +464,22 @@