From 15463bc292bc56c6a53438f216d83520f1a038fa Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Tue, 1 Sep 2026 22:42:06 +0200 Subject: [PATCH] =?UTF-8?q?"Vorgang":=20Fallmappe=20f=C3=BCr=20Klassenbuch?= =?UTF-8?q?-=20und=20Dokumentationseintr=C3=A4ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer 4. Tab in der Klassenlehreransicht: bündelt Titel, Beschreibung, Schlagwörter, verknüpfte Dokumentation und angeheftete (eingefrorene) WebUntis-Klassenbucheinträge zu einem laufenden Problem mit einer/einem oder mehreren Schüler*innen. Anheften direkt aus dem Klassenbuch-Tab per Rechtsklick. Sync-fähig nach dem bestehenden Documentation-Muster. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 9 + LehrerApp.Core/Models/Vorgang.cs | 49 ++++ .../ChangeHookCascadeTests.cs | 17 ++ LehrerApp.Data.Tests/RepositoryTests.cs | 61 +++++ LehrerApp.Data/LiteDbContext.cs | 1 + .../Repositories/AllRepositories.cs | 31 +++ .../ClassTeacherViewModelsTests.cs | 49 ++++ LehrerApp.Desktop/AppBootstrapper.cs | 2 + .../ClassTeacherCasesViewModel.cs | 235 ++++++++++++++++++ .../ClassTeacherDetailsViewModel.cs | 46 +++- .../ClassTeacherOverviewViewModel.cs | 5 +- .../ClassTeacher/VorgangViewModels.cs | 204 +++++++++++++++ .../ClassTeacher/ClassTeacherCasesView.axaml | 187 ++++++++++++++ .../ClassTeacherCasesView.axaml.cs | 63 +++++ .../ClassTeacherOverviewView.axaml | 3 + .../ClassTeacherRegisterView.axaml | 13 +- .../ClassTeacherRegisterView.axaml.cs | 15 ++ .../ClassTeacher/PinToVorgangDialog.axaml | 44 ++++ .../ClassTeacher/PinToVorgangDialog.axaml.cs | 21 ++ .../Views/ClassTeacher/VorgangDialog.axaml | 80 ++++++ .../Views/ClassTeacher/VorgangDialog.axaml.cs | 21 ++ LehrerApp.Sync/EventApplier.cs | 1 + TODO.md | 39 +++ 23 files changed, 1191 insertions(+), 5 deletions(-) create mode 100644 LehrerApp.Core/Models/Vorgang.cs create mode 100644 LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/PinToVorgangDialog.axaml create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/PinToVorgangDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/VorgangDialog.axaml create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/VorgangDialog.axaml.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 151132d..0c8b83c 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -200,6 +200,15 @@ public interface IDocumentationRepository /// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2). void HardDelete(Guid id); } +public interface IVorgangRepository +{ + List GetAll(); + List GetByStudent(Guid studentId); + Vorgang? GetById(Guid id); + void Save(Vorgang vorgang); + /// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation). + void Delete(Guid id); +} public interface IWorkTaskRepository { List GetByStatus(WorkTaskStatus status); diff --git a/LehrerApp.Core/Models/Vorgang.cs b/LehrerApp.Core/Models/Vorgang.cs new file mode 100644 index 0000000..284a601 --- /dev/null +++ b/LehrerApp.Core/Models/Vorgang.cs @@ -0,0 +1,49 @@ +namespace LehrerApp.Core.Models; + +/// +/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen +/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel, +/// Problembeschreibung, Schlagwörter sowie die dazugehörigen -Einträge +/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte +/// Listen verstreut zu lassen. Bewusst nur Referenzen () statt +/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur +/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall +/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen). +/// +public class Vorgang +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public List StudentIds { get; set; } = []; + /// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" — + /// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste. + public List Tags { get; set; } = []; + public VorgangStatus Status { get; set; } = VorgangStatus.Open; + public List DocumentationIds { get; set; } = []; + public List ClassRegisterEntries { get; set; } = []; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ClosedAt { get; set; } + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } +} + +public enum VorgangStatus { Open, Closed } + +/// +/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen +/// — keine Referenz per ID, da weder UntisForeignClassRegisterEventDto +/// noch UntisClassRegisterCacheEntry eine über einen Cache-Refresh hinweg stabile ID haben. +/// +public class VorgangClassRegisterEntry +{ + public DateOnly Date { get; set; } + public string StudentName { get; set; } = ""; + public string? Subject { get; set; } + public string? TeacherUsername { get; set; } + public string? CategoryName { get; set; } + public string? CategoryGroup { get; set; } + public string? Text { get; set; } + public DateTime PinnedAt { get; set; } = DateTime.UtcNow; +} diff --git a/LehrerApp.Data.Tests/ChangeHookCascadeTests.cs b/LehrerApp.Data.Tests/ChangeHookCascadeTests.cs index 61249d7..5968e88 100644 --- a/LehrerApp.Data.Tests/ChangeHookCascadeTests.cs +++ b/LehrerApp.Data.Tests/ChangeHookCascadeTests.cs @@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests var call = Assert.Single(calls); Assert.Equal((nameof(Documentation), "Delete"), call); } + + [Fact] + public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus() + { + using var db = NewInMemoryContext(); + var repo = new VorgangRepository(db); + var vorgang = new Vorgang { Title = "Konflikt" }; + repo.Save(vorgang); + var calls = new List<(string EntityType, string Operation, object? Payload)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload)); + + repo.Delete(vorgang.Id); + + var call = Assert.Single(calls); + Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation)); + Assert.True(((Vorgang)call.Payload!).IsDeleted); + } } diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index d6486a2..8aa38ae 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -715,6 +715,67 @@ public sealed class RepositoryTests Assert.Null(storage.OpenRead(attachmentId)); } + // ── VorgangRepository ───────────────────────────────────────────────────── + + [Fact] + public void VorgangRepository_Save_SetztUpdatedAt() + { + using var db = NewInMemoryContext(); + var repo = new VorgangRepository(db); + var vorgang = new Vorgang { Title = "Schuleschwänzen Max" }; + + repo.Save(vorgang); + + var raw = db.Vorgaenge.FindById(vorgang.Id); + Assert.NotNull(raw); + Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5); + } + + [Fact] + public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern() + { + using var db = NewInMemoryContext(); + var repo = new VorgangRepository(db); + var ada = Guid.NewGuid(); + var ben = Guid.NewGuid(); + var other = Guid.NewGuid(); + var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] }; + var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] }; + repo.Save(shared); + repo.Save(unrelated); + + Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id)); + Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id)); + Assert.Empty(repo.GetByStudent(Guid.NewGuid())); + } + + [Fact] + public void VorgangRepository_Delete_MarkiertNurAlsGeloescht() + { + using var db = NewInMemoryContext(); + var repo = new VorgangRepository(db); + var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" }; + repo.Save(vorgang); + + repo.Delete(vorgang.Id); + + Assert.Empty(repo.GetAll()); + Assert.Null(repo.GetById(vorgang.Id)); + var raw = db.Vorgaenge.FindById(vorgang.Id); + Assert.NotNull(raw); + Assert.True(raw!.IsDeleted); + Assert.NotNull(raw.DeletedAt); + } + + [Fact] + public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId() + { + using var db = NewInMemoryContext(); + var repo = new VorgangRepository(db); + + Assert.Null(repo.GetById(Guid.NewGuid())); + } + // ── LessonRepository ────────────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index fa92412..d8ecd85 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable public ILiteCollection Units => _db.GetCollection("units"); public ILiteCollection Lessons => _db.GetCollection("lessons"); public ILiteCollection Documentation => _db.GetCollection("documentation"); + public ILiteCollection Vorgaenge => _db.GetCollection("vorgaenge"); public ILiteStorage Attachments => _db.GetStorage("attachments", "attachments_chunks"); public ILiteCollection Tasks => _db.GetCollection("tasks"); public ILiteCollection TimeEntries => _db.GetCollection("time_entries"); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index c3ece27..8cf36e3 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -431,6 +431,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor } } +public class VorgangRepository(LiteDbContext db) : IVorgangRepository +{ + public List GetAll() => + db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList(); + public List GetByStudent(Guid studentId) => + GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList(); + public Vorgang? GetById(Guid id) + { + var vorgang = db.Vorgaenge.FindById(id); + return vorgang is null || vorgang.IsDeleted ? null : vorgang; + } + public void Save(Vorgang vorgang) + { + vorgang.UpdatedAt = DateTime.UtcNow; + db.Vorgaenge.Upsert(vorgang); + db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang); + } + public void Delete(Guid id) + { + var vorgang = db.Vorgaenge.FindById(id); + if (vorgang is null) return; + vorgang.IsDeleted = true; + vorgang.DeletedAt = DateTime.UtcNow; + db.Vorgaenge.Update(vorgang); + // Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein + // anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen + // (gleiches Muster wie DocumentationRepository.Delete). + db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang); + } +} + public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository { public List GetByStatus(WorkTaskStatus s) => diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 04a5c5d..b2d4966 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -840,4 +840,53 @@ public sealed class ClassTeacherViewModelsTests Assert.Equal(["Ada"], result.Select(d => d.Title)); } + + // ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ─────────────────────────── + + [Fact] + public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile() + { + var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", + "mueller", "Fehlende HA", "Negativ", "Buch vergessen"); + + var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row); + + Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date); + Assert.Equal("Schmidt Ben", snapshot.StudentName); + Assert.Equal("Deu", snapshot.Subject); + Assert.Equal("mueller", snapshot.TeacherUsername); + Assert.Equal("Fehlende HA", snapshot.CategoryName); + Assert.Equal("Negativ", snapshot.CategoryGroup); + Assert.Equal("Buch vergessen", snapshot.Text); + } + + [Fact] + public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler() + { + var ada = Guid.NewGuid(); + var ben = Guid.NewGuid(); + var fremd = Guid.NewGuid(); + var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" }; + var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" }; + var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" }; + var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" }; + var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] }; + var allDocs = new List { linked, unlinked, otherStudentInCase, unrelated }; + + var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang); + + Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title)); + } + + [Fact] + public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege() + { + var ada = Guid.NewGuid(); + var doc = new Documentation { StudentId = ada, Title = "Verknüpft" }; + var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] }; + + var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang); + + Assert.Empty(result); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index aa8d60a..2c1e60b 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -162,6 +162,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -315,6 +316,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs new file mode 100644 index 0000000..ff01c95 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs @@ -0,0 +1,235 @@ +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; + +/// +/// "Vorgänge"-Tab der Klassenlehreransicht: Fallmappen für konkrete, laufende Probleme mit einer/ +/// einem oder mehreren Schüler*innen der Klasse, die Dokumentation und (eingefrorene) WebUntis- +/// Klassenbucheinträge bündeln. Löst wie die Untis- +/// Roster-Namen der Klasse auf lokale -Datensätze auf (bewusst ein eigener, +/// nicht geteilter Abgleich — dasselbe kleine Muster steckt schon zweimal im Code, siehe +/// , eine gemeinsame Abstraktion dafür ist +/// hier nicht Teil der Aufgabe). +/// +public partial class ClassTeacherCasesViewModel : ObservableObject +{ + private readonly IVorgangRepository _vorgaenge; + private readonly IDocumentationRepository _documentation; + private readonly IStudentRepository _students; + private readonly UntisReportCacheService _cache; + + private string _className = ""; + private List _rosterStudentOptions = []; + + public ObservableCollection Cases { get; } = []; + + [ObservableProperty] private bool _onlyOpen = true; + [ObservableProperty] private bool _busy; + [ObservableProperty] private string _status = ""; + + public bool HasCases => Cases.Count > 0; + + public Func, Vorgang?, Task>? OnEditVorgang { get; set; } + public Func>? OnConfirmDeleteVorgang { get; set; } + public Func, Documentation?, Task>? OnEditDocumentation { get; set; } + + public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation, + IStudentRepository students, UntisReportCacheService cache) + { + _vorgaenge = vorgaenge; + _documentation = documentation; + _students = students; + _cache = cache; + } + + public void Initialize(string className) + { + _className = className; + Cases.Clear(); + Status = "Wird geladen…"; + NotifyState(); + _ = LoadInternal(); + } + + partial void OnOnlyOpenChanged(bool value) => _ = LoadInternal(); + + [RelayCommand] + private Task Refresh() => LoadInternal(); + + private async Task LoadInternal() + { + if (string.IsNullOrWhiteSpace(_className)) return; + + // IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen + // pro Load) - deshalb hier gemerkt und danach wiederhergestellt, sonst klappt eine gerade + // aufgeklappte Karte bei jeder Verknüpfungs-/Status-Aktion (die intern neu lädt) wieder zu. + var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet(); + + Busy = true; + Cases.Clear(); + NotifyState(); + try + { + var roster = await _cache.GetStudentRosterAsync(_className); + var localStudents = _students.GetAll(); + var matches = roster + .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(); + _rosterStudentOptions = matches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList(); + var rosterIds = matches.Select(m => m.StudentId).ToHashSet(); + var nameById = matches.ToDictionary(m => m.StudentId, m => m.DisplayName); + + var relevant = _vorgaenge.GetAll().Where(v => v.StudentIds.Any(rosterIds.Contains)); + if (OnlyOpen) relevant = relevant.Where(v => v.Status == VorgangStatus.Open); + + var allDocs = _documentation.GetAll(); + foreach (var vorgang in relevant.OrderByDescending(v => v.UpdatedAt)) + { + var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, "")) + .Where(n => n != ""); + var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation, + UnlinkDocumentation, RemoveClassRegisterEntry) { IsRevealed = revealedIds.Contains(vorgang.Id) }; + + var linkedIds = vorgang.DocumentationIds.ToHashSet(); + foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id))) + item.LinkedDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, ""))); + foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang)) + item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, ""))); + foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date)) + item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry)); + + Cases.Add(item); + } + Status = OnlyOpen ? $"{Cases.Count} offene Vorgänge" : $"{Cases.Count} Vorgänge"; + } + finally { Busy = false; NotifyState(); } + } + + private void NotifyState() => OnPropertyChanged(nameof(HasCases)); + + [RelayCommand] + private async Task AddVorgang() + { + if (OnEditVorgang is null) return; + var result = await OnEditVorgang(_rosterStudentOptions, null); + if (result is null) return; + _vorgaenge.Save(result); + await LoadInternal(); + } + + [RelayCommand] + private async Task EditVorgang(VorgangItem? item) + { + if (item is null || OnEditVorgang is null) return; + var result = await OnEditVorgang(_rosterStudentOptions, item.Model); + if (result is null) return; + _vorgaenge.Save(result); + await LoadInternal(); + } + + [RelayCommand] + private async Task ToggleStatus(VorgangItem? item) + { + if (item is null) return; + item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open; + item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null; + _vorgaenge.Save(item.Model); + await LoadInternal(); + } + + [RelayCommand] + private async Task DeleteVorgang(VorgangItem? item) + { + if (item is null) return; + if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return; + _vorgaenge.Delete(item.Model.Id); + await LoadInternal(); + } + + [RelayCommand] + private async Task CreateAndLinkDocumentation(VorgangItem? item) + { + if (item is null || OnEditDocumentation is null) return; + var studentOptions = _rosterStudentOptions.Where(s => item.Model.StudentIds.Contains(s.Id)).ToList(); + var defaultStudentId = item.Model.StudentIds.FirstOrDefault(); + var result = await OnEditDocumentation(defaultStudentId, studentOptions, null); + if (result is null) return; + _documentation.Save(result); + item.Model.DocumentationIds.Add(result.Id); + _vorgaenge.Save(item.Model); + await LoadInternal(); + } + + /// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist — + /// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie + /// ). + public static List FilterAvailableDocumentation(IReadOnlyList allDocs, Vorgang vorgang) + { + var linkedIds = vorgang.DocumentationIds.ToHashSet(); + return allDocs.Where(d => vorgang.StudentIds.Contains(d.StudentId) && !linkedIds.Contains(d.Id)).ToList(); + } + + private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc) + { + item.Model.DocumentationIds.Add(doc.Model.Id); + _vorgaenge.Save(item.Model); + await LoadInternal(); + } + + private async Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc) + { + item.Model.DocumentationIds.Remove(doc.Model.Id); + _vorgaenge.Save(item.Model); + await LoadInternal(); + } + + private async Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row) + { + item.Model.ClassRegisterEntries.Remove(row.Model); + _vorgaenge.Save(item.Model); + await LoadInternal(); + } + + // ── Anheften eines Klassenbuch-Eintrags aus dem Klassenbuch-Tab (ClassTeacherDetailsViewModel, + // per Konstruktor-Injection dieser Instanz — kein Func-Hook, da hier keine UI im Spiel ist, + // nur Zugriff auf schon geladene Daten dieser Geschwister-ViewModel-Instanz) ──────────────── + + public IReadOnlyList RosterStudentOptions => _rosterStudentOptions; + + /// Offene Vorgänge, an die sich ein Klassenbuch-Eintrag für diese/n Schüler*in anheften lässt + /// (Namensabgleich wie beim restlichen Klassenlehrer-Bereich, siehe UntisNameMatching). + public List OpenCasesForStudent(string untisDisplayName) + { + var match = _rosterStudentOptions.FirstOrDefault(s => + UntisNameMatching.NamesMatch(s.Name, untisDisplayName)); + if (match is null) return []; + return Cases.Where(c => c.IsOpen && c.Model.StudentIds.Contains(match.Id)).ToList(); + } + + public async Task PinClassRegisterEntryAsync(Guid vorgangId, VorgangClassRegisterEntry entry) + { + var vorgang = _vorgaenge.GetById(vorgangId); + if (vorgang is null) return; + vorgang.ClassRegisterEntries.Add(entry); + _vorgaenge.Save(vorgang); + await LoadInternal(); + } + + public async Task CreateAndPinAsync(string title, Guid studentId, VorgangClassRegisterEntry entry) + { + var vorgang = new Vorgang { Title = title, StudentIds = [studentId] }; + vorgang.ClassRegisterEntries.Add(entry); + _vorgaenge.Save(vorgang); + await LoadInternal(); + return vorgang; + } +} diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index e4e5629..a719878 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -135,6 +135,7 @@ 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; @@ -149,6 +150,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public ObservableCollection CategoryAggregates { get; } = []; public ObservableCollection OwnDocumentationEntries { 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."; @@ -164,6 +168,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject 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; @@ -177,11 +184,12 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject ? "Alle Schüler*innen" : StudentFilter; public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation, - IStudentRepository students) + IStudentRepository students, ClassTeacherCasesViewModel cases) { _cache = cache; _documentation = documentation; _students = students; + _cases = cases; } public void Initialize(string className) @@ -359,6 +367,42 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject 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. diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 5d461ec..fd0dc8e 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -322,6 +322,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject private readonly AppLogger? _logger; public ClassTeacherDetailsViewModel DetailsTab { get; } + public ClassTeacherCasesViewModel CasesTab { get; } public ObservableCollection Roster { get; } = []; public ObservableCollection PrimaryRoster { get; } = []; public ObservableCollection SecondaryRoster { get; } = []; @@ -406,7 +407,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation, IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab, - AppLogger? logger = null) + ClassTeacherCasesViewModel casesTab, AppLogger? logger = null) { _settings = settings; _untis = untis; @@ -419,6 +420,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject _participationSessions = participationSessions; _logger = logger; DetailsTab = detailsTab; + CasesTab = casesTab; } partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); @@ -451,6 +453,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject var className = HomeroomClassName!; DetailsTab.Initialize(className); + CasesTab.Initialize(className); Busy = true; NotifyRosterState(); try diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs new file mode 100644 index 0000000..b6de0a5 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs @@ -0,0 +1,204 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Students; + +namespace LehrerApp.Desktop.ViewModels.ClassTeacher; + +// ── Anzeige-Helfer ──────────────────────────────────────────────────────── + +public static class VorgangTagDisplay +{ + // Vom Nutzer selbst genannte Schlagwörter — eigene Labels bleiben trotzdem frei möglich + // (AutoCompleteBox, gleiches Muster wie DocumentationTagDisplay.Suggestions). + public static string[] Suggestions { get; } = + ["Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte", "Mitarbeit", "Elternkontakt", "Eskalation"]; +} + +public static class VorgangStatusDisplay +{ + public static string Label(VorgangStatus status) => status == VorgangStatus.Closed ? "Geschlossen" : "Offen"; +} + +/// Ergebnis des "→ Vorgang"-Auswahldialogs beim Anheften einer Klassenbuchzeile: entweder ein +/// bestehender, offener Vorgang oder der Titel für einen neu anzulegenden. +public sealed record PinToVorgangChoice(Guid? ExistingVorgangId, string? NewVorgangTitle); + +/// Eingefrorene WebUntis-Klassenbuchzeile mit deutscher Anzeige-Aufbereitung — das Core-Modell +/// bleibt bewusst framework-frei ohne Formatierungslogik. +public sealed record VorgangClassRegisterEntryRow(VorgangClassRegisterEntry Model) +{ + public string DateLabel => Model.Date.ToString("dd.MM.yyyy"); + public string StudentName => Model.StudentName; + public string SummaryLabel => string.Join(" · ", new[] { Model.Subject, Model.CategoryName, Model.Text } + .Where(v => !string.IsNullOrWhiteSpace(v))); +} + +// ── Mehrfachauswahl Schüler*innen (Anlegen/Bearbeiten-Dialog) ──────────────── + +public partial class VorgangStudentOption(Guid studentId, string name) : ObservableObject +{ + public Guid StudentId { get; } = studentId; + public string Name { get; } = name; + [ObservableProperty] private bool _isSelected; +} + +// ── Listen-Eintrag mit aufklappbarer Detailansicht ─────────────────────────── + +/// +/// Ein Vorgang in der Liste des "Vorgänge"-Tabs. Verknüpfte Dokumentation/Klassenbucheinträge +/// werden von beim Laden befüllt; Link/Unlink-Aktionen +/// laufen über die hier übergebenen Callbacks zurück ins ViewModel (statt eigenem Repository- +/// Zugriff hier), damit dieser Wrapper ein reiner Anzeige-Baustein bleibt — analog +/// . +/// +public partial class VorgangItem : ObservableObject +{ + private readonly Func _onLink; + private readonly Func _onUnlink; + private readonly Func _onRemoveClassRegisterEntry; + + public Vorgang Model { get; } + public string StudentNames { get; } + public List TagChips { get; } + public string StatusLabel => VorgangStatusDisplay.Label(Model.Status); + public bool IsOpen => Model.Status == VorgangStatus.Open; + public string CreatedLabel => $"Angelegt {Model.CreatedAt:dd.MM.yyyy}"; + + public ObservableCollection LinkedDocumentation { get; } = []; + public ObservableCollection AvailableDocumentation { get; } = []; + public ObservableCollection ClassRegisterRows { get; } = []; + + public bool HasLinkedDocumentation => LinkedDocumentation.Count > 0; + public bool HasAvailableDocumentation => AvailableDocumentation.Count > 0; + public bool HasClassRegisterRows => ClassRegisterRows.Count > 0; + + [ObservableProperty] private bool _isRevealed; + + public VorgangItem(Vorgang model, string studentNames, + Func onLink, + Func onUnlink, + Func onRemoveClassRegisterEntry) + { + Model = model; + StudentNames = studentNames; + TagChips = model.Tags.Select(t => new TagChip(t)).ToList(); + _onLink = onLink; + _onUnlink = onUnlink; + _onRemoveClassRegisterEntry = onRemoveClassRegisterEntry; + } + + [RelayCommand] private void Reveal() => IsRevealed = !IsRevealed; + + [RelayCommand] + private async Task Link(DocumentationItem? doc) + { + if (doc is not null) await _onLink(this, doc); + } + + [RelayCommand] + private async Task Unlink(DocumentationItem? doc) + { + if (doc is not null) await _onUnlink(this, doc); + } + + [RelayCommand] + private async Task RemoveClassRegisterEntry(VorgangClassRegisterEntryRow? row) + { + if (row is not null) await _onRemoveClassRegisterEntry(this, row); + } +} + +// ── Dialog: Vorgang anlegen/bearbeiten ──────────────────────────────────── + +public partial class VorgangDialogViewModel : ObservableObject +{ + private readonly Vorgang? _editing; + + public ObservableCollection StudentOptions { get; } + + [ObservableProperty] private string _title = ""; + [ObservableProperty] private string _description = ""; + [ObservableProperty] private string _newTag = ""; + public ObservableCollection Tags { get; } = []; + public string[] TagSuggestions => VorgangTagDisplay.Suggestions; + + [ObservableProperty] private string _titleError = ""; + [ObservableProperty] private string _studentsError = ""; + + public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten"; + public Vorgang? Result { get; private set; } + + public VorgangDialogViewModel(List rosterStudents, Vorgang? editing) + { + _editing = editing; + StudentOptions = new ObservableCollection(rosterStudents.Select(s => + new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true })); + if (editing is null) return; + + Title = editing.Title; + Description = editing.Description; + foreach (var tag in editing.Tags) Tags.Add(tag); + } + + [RelayCommand] + private void AddTag() + { + if (string.IsNullOrWhiteSpace(NewTag)) return; + var tag = NewTag.Trim(); + if (!Tags.Contains(tag)) Tags.Add(tag); + NewTag = ""; + } + + [RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); } + + [RelayCommand] + private void Save() + { + TitleError = ""; StudentsError = ""; + var valid = true; + + if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } + var selectedIds = StudentOptions.Where(s => s.IsSelected).Select(s => s.StudentId).ToList(); + if (selectedIds.Count == 0) { StudentsError = "Mindestens eine/n Schüler*in auswählen."; valid = false; } + + if (!valid) return; + + Result = _editing ?? new Vorgang(); + Result.Title = Title.Trim(); + Result.Description = (Description ?? "").Trim(); + Result.StudentIds = selectedIds; + Result.Tags = Tags.ToList(); + } +} + +// ── Dialog: Klassenbuchzeile an (bestehenden oder neuen) Vorgang anheften ──── + +public partial class PinToVorgangDialogViewModel : ObservableObject +{ + public string RowSummary { get; } + public List MatchingOpenCases { get; } + public bool HasMatchingOpenCases => MatchingOpenCases.Count > 0; + + [ObservableProperty] private VorgangItem? _selectedCase; + [ObservableProperty] private string _newTitle = ""; + [ObservableProperty] private string _error = ""; + + public PinToVorgangChoice? Result { get; private set; } + + public PinToVorgangDialogViewModel(string rowSummary, List matchingOpenCases) + { + RowSummary = rowSummary; + MatchingOpenCases = matchingOpenCases; + } + + [RelayCommand] + private void Confirm() + { + Error = ""; + if (SelectedCase is not null) { Result = new PinToVorgangChoice(SelectedCase.Model.Id, null); return; } + if (!string.IsNullOrWhiteSpace(NewTitle)) { Result = new PinToVorgangChoice(null, NewTitle.Trim()); return; } + Error = "Bestehenden Vorgang wählen oder Titel für einen neuen Vorgang eingeben."; + } +} diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml new file mode 100644 index 0000000..ab53b82 --- /dev/null +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + +