diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs index 2a2d3f1..873f355 100644 --- a/LehrerApp.Core/Models/Workload.cs +++ b/LehrerApp.Core/Models/Workload.cs @@ -3,7 +3,14 @@ namespace LehrerApp.Core.Models; public class Documentation : IHasAttachments { public Guid Id { get; set; } = Guid.NewGuid(); + /// Hauptperson des Eintrags (bei Gruppeneinträgen ). public Guid StudentId { get; set; } + /// + /// Weitere betroffene Schüler*innen, z.B. bei einem Vorgang mit mehreren Beteiligten: der Eintrag + /// existiert nur einmal, erscheint aber bei jeder dieser Personen (siehe ). + /// Enthält nie selbst. + /// + public List AdditionalStudentIds { get; set; } = []; public Guid? GroupId { get; set; } /// Unterrichtssitzung, in der die Beobachtung entstanden ist. public Guid? ParticipationSessionId { get; set; } @@ -33,6 +40,14 @@ public class Documentation : IHasAttachments // Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4). public bool IsDeleted { get; set; } public DateTime? DeletedAt { get; set; } + + /// Ob der Eintrag diese Person betrifft — als Hauptperson oder als weitere Beteiligte. + public bool Concerns(Guid studentId) => + studentId != Guid.Empty && (StudentId == studentId || AdditionalStudentIds.Contains(studentId)); + + /// Hauptperson (falls gesetzt) gefolgt von den weiteren Beteiligten. + public IEnumerable InvolvedStudentIds() => + (StudentId == Guid.Empty ? [] : new[] { StudentId }).Concat(AdditionalStudentIds); } public class AbsenceData { diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index c6cc065..c39904b 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -1338,4 +1338,39 @@ public sealed class RepositoryTests Assert.Null(repo.GetById(extra.Id)); } + + [Fact] + public void DocumentationRepository_GetByStudent_FindetGemeinsamenEintragBeiAllenBeteiligten() + { + using var db = NewInMemoryContext(); + var repo = new DocumentationRepository(db); + Guid anna = Guid.NewGuid(), ben = Guid.NewGuid(), cem = Guid.NewGuid(); + var shared = new Documentation + { + StudentId = anna, AdditionalStudentIds = [ben], Title = "Konflikt", Type = DocumentationType.Incident, + }; + repo.Save(shared); + + Assert.Equal(shared.Id, Assert.Single(repo.GetByStudent(anna)).Id); + Assert.Equal(shared.Id, Assert.Single(repo.GetByStudent(ben)).Id); + Assert.Empty(repo.GetByStudent(cem)); + Assert.Single(repo.GetByStudentAndType(ben, DocumentationType.Incident)); + Assert.Empty(repo.GetByStudentAndType(ben, DocumentationType.Conversation)); + } + + [Fact] + public void StudentRepository_GetReferenceSummary_ZaehltAuchGemeinsameDokumentation() + { + using var db = NewInMemoryContext(); + var students = new StudentRepository(db); + var ben = new Student { FirstName = "Ben", LastName = "Muster" }; + students.Save(ben); + new DocumentationRepository(db).Save(new Documentation + { + StudentId = Guid.NewGuid(), AdditionalStudentIds = [ben.Id], Title = "Konflikt", + }); + + Assert.Equal(1, students.GetReferenceSummary(ben.Id).DocumentationEntries); + Assert.Throws(() => students.Delete(ben.Id)); + } } diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index e938299..fc644f4 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -34,7 +34,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository db.Grades.Count(g => g.StudentId == studentId), db.ReportGrades.Count(g => g.StudentId == studentId), db.ParticipationEntries.Count(e => e.StudentId == studentId), - db.Documentation.Count(d => d.StudentId == studentId)); + db.Documentation.FindAll().Count(d => d.Concerns(studentId))); public void Save(Student s) { s.ExternalIds ??= []; @@ -415,10 +415,13 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository { + // Filter auf AdditionalStudentIds bewusst im Speicher statt als LiteDB-Ausdruck — die Menge + // an Dokumentation pro Lehrkraft ist klein, und so gilt exakt dieselbe Regel wie Documentation.Concerns. public List GetByStudent(Guid id) => - db.Documentation.Find(d => d.StudentId == id && !d.IsDeleted).OrderByDescending(d => d.Date).ToList(); + db.Documentation.Find(d => !d.IsDeleted).Where(d => d.Concerns(id)) + .OrderByDescending(d => d.Date).ToList(); public List GetByStudentAndType(Guid sid, DocumentationType type) => - db.Documentation.Find(d => d.StudentId == sid && d.Type == type && !d.IsDeleted) + db.Documentation.Find(d => d.Type == type && !d.IsDeleted).Where(d => d.Concerns(sid)) .OrderByDescending(d => d.Date).ToList(); public List GetAll() => db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList(); diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 8b1c354..e377194 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -1260,6 +1260,18 @@ public sealed class ClassTeacherViewModelsTests Assert.Empty(result); } + [Fact] + public void FilterAvailableDocumentation_BeruecksichtigtWeitereBeteiligte() + { + Guid ada = Guid.NewGuid(), ben = Guid.NewGuid(); + var shared = new Documentation { StudentId = Guid.NewGuid(), AdditionalStudentIds = [ben] }; + var vorgang = new Vorgang { StudentIds = [ada, ben] }; + + var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([shared], vorgang); + + Assert.Equal(shared.Id, Assert.Single(result).Id); + } + // ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ───────── [Fact] diff --git a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs index 7f5d394..b17678e 100644 --- a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs @@ -308,4 +308,89 @@ public sealed class DocumentationDialogViewModelTests Assert.Null(storage.OpenRead(storageId)); } + + // ── Weitere Beteiligte ("Betrifft außerdem") ───────────────────────────── + + [Fact] + public void WeitereBeteiligte_NeuerEintrag_UebernimmtAuswahlOhneHauptperson() + { + var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel"); + var ben = new StudentOption(Guid.NewGuid(), "Ben Muster"); + var cem = new StudentOption(Guid.NewGuid(), "Cem Test"); + var vm = new DocumentationDialogViewModel(anna.Id, null, new FakeAttachmentStorage(), + coStudentOptions: [anna, ben, cem]) + { + Title = "Konflikt", TypeName = "Vorkommnis", + }; + Assert.True(vm.HasCoStudents); + Assert.True(vm.CoStudents.Single(c => c.Id == anna.Id).IsPrimary); + foreach (var c in vm.CoStudents) c.IsSelected = true; + + vm.SaveCommand.Execute(null); + + Assert.Equal(anna.Id, vm.Result!.StudentId); + Assert.Equal([ben.Id, cem.Id], vm.Result.AdditionalStudentIds); + } + + [Fact] + public void WeitereBeteiligte_NurHauptpersonAlsKandidat_BlendetAuswahlAus() + { + var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel"); + var vm = new DocumentationDialogViewModel(anna.Id, null, new FakeAttachmentStorage(), coStudentOptions: [anna]); + + Assert.False(vm.HasCoStudents); + } + + [Fact] + public void WeitereBeteiligte_WechselDerHauptperson_VerschiebtAusblendung() + { + var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel"); + var ben = new StudentOption(Guid.NewGuid(), "Ben Muster"); + var vm = new DocumentationDialogViewModel(anna.Id, null, new FakeAttachmentStorage(), [anna, ben], + coStudentOptions: [anna, ben]) + { + Title = "Konflikt", TypeName = "Vorkommnis", + }; + vm.CoStudents.Single(c => c.Id == anna.Id).IsSelected = true; + + vm.SelectedStudent = ben; + vm.SaveCommand.Execute(null); + + Assert.True(vm.CoStudents.Single(c => c.Id == ben.Id).IsPrimary); + Assert.Equal(ben.Id, vm.Result!.StudentId); + Assert.Equal([anna.Id], vm.Result.AdditionalStudentIds); + } + + [Fact] + public void WeitereBeteiligte_BearbeitenOhneKandidaten_BehaeltBestehendeBei() + { + Guid anna = Guid.NewGuid(), ben = Guid.NewGuid(); + var existing = new Documentation { StudentId = anna, AdditionalStudentIds = [ben], Title = "Konflikt" }; + var vm = new DocumentationDialogViewModel(ben, existing, new FakeAttachmentStorage()); + + vm.SaveCommand.Execute(null); + + Assert.Equal(anna, vm.Result!.StudentId); + Assert.Equal([ben], vm.Result.AdditionalStudentIds); + } + + [Fact] + public void WeitereBeteiligte_Bearbeiten_AbwahlEntferntNurAngeboteneKandidaten() + { + var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel"); + var ben = new StudentOption(Guid.NewGuid(), "Ben Muster"); + var outsider = Guid.NewGuid(); + var existing = new Documentation + { + StudentId = anna.Id, AdditionalStudentIds = [ben.Id, outsider], Title = "Konflikt", + }; + var vm = new DocumentationDialogViewModel(anna.Id, existing, new FakeAttachmentStorage(), + coStudentOptions: [anna, ben]); + Assert.True(vm.CoStudents.Single(c => c.Id == ben.Id).IsSelected); + + vm.CoStudents.Single(c => c.Id == ben.Id).IsSelected = false; + vm.SaveCommand.Execute(null); + + Assert.Equal([outsider], vm.Result!.AdditionalStudentIds); + } } diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 978e397..555c365 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -320,9 +320,9 @@ public class FakeDocumentation : IDocumentationRepository private readonly List _all = []; public void Add(Documentation d) => _all.Add(d); public List GetByStudent(Guid studentId) => - _all.Where(d => d.StudentId == studentId && !d.IsDeleted).ToList(); + _all.Where(d => d.Concerns(studentId) && !d.IsDeleted).ToList(); public List GetByStudentAndType(Guid studentId, DocumentationType type) => - _all.Where(d => d.StudentId == studentId && d.Type == type && !d.IsDeleted).ToList(); + _all.Where(d => d.Concerns(studentId) && d.Type == type && !d.IsDeleted).ToList(); public List GetAll() => _all.Where(d => !d.IsDeleted).ToList(); public void Save(Documentation doc) { _all.RemoveAll(d => d.Id == doc.Id); _all.Add(doc); } public void Delete(Guid id) { var d = _all.FirstOrDefault(x => x.Id == id); if (d is not null) d.IsDeleted = true; } diff --git a/LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs b/LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs index 631dedd..e87c4db 100644 --- a/LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs +++ b/LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs @@ -136,6 +136,38 @@ public sealed class StudentWarningEvidenceTests Assert.False(vm.IsPersisted(draft)); } + [Fact] + public async Task GemeinsamerVorgang_DokumentationFuerMehrereErscheintAuchBeimZweitenSchueler() + { + var ben = new Student { FirstName = "Ben", LastName = "Muster" }; + var students = new FakeStudents([_student, ben]); + var vorgaenge = new FakeVorgangRepository(); + var vorgang = new Vorgang { Title = "Streit", StudentIds = [_student.Id, ben.Id], Tags = ["Konflikte"] }; + vorgaenge.Add(vorgang); + var documentation = new FakeDocumentation(); + var vm = new StudentVorgaengeViewModel(vorgaenge, documentation, students); + vm.Load(_student.Id); + List offered = []; + vm.OnEditDocumentation = (studentId, coStudents, _) => + { + offered = coStudents; + return Task.FromResult(new Documentation + { + StudentId = studentId, AdditionalStudentIds = [ben.Id], Title = "Gespräch mit beiden", + }); + }; + + await vm.CreateAndLinkDocumentationCommand.ExecuteAsync(Assert.Single(vm.Cases)); + + Assert.Equal([_student.Id, ben.Id], offered.Select(o => o.Id)); + var benView = new StudentVorgaengeViewModel(vorgaenge, documentation, students); + benView.Load(ben.Id); + var linked = Assert.Single(Assert.Single(benView.Cases).LinkedDocumentation); + Assert.Equal("Gespräch mit beiden", linked.Model.Title); + Assert.Contains("Muster", linked.StudentName); + Assert.Contains("Beispiel", linked.StudentName); + } + [Fact] public async Task VorlageAusVorgangstab_OeffnetEntwurfUndVerknuepftNachSpeichern() { @@ -147,7 +179,7 @@ public sealed class StudentWarningEvidenceTests var vm = new StudentVorgaengeViewModel(vorgaenge, documentation, new FakeStudents([_student]), BuildService()); vm.Load(_student.Id); var wasDraft = false; - vm.OnEditDocumentation = (_, editing) => + vm.OnEditDocumentation = (_, _, editing) => { wasDraft = vm.IsNewDocumentationDraft(editing!); return Task.FromResult(editing); diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs index 052e38a..46ed01a 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs @@ -42,7 +42,8 @@ public partial class ClassTeacherCasesViewModel : ObservableObject public Func, Vorgang?, Task>? OnEditVorgang { get; set; } public Func>? OnConfirmDeleteVorgang { get; set; } - public Func, Documentation?, Task>? OnEditDocumentation { get; set; } + /// Parameter: Hauptperson, Bezug-Auswahl, Kandidaten für "Betrifft außerdem", bestehender Eintrag. + public Func, List, Documentation?, Task>? OnEditDocumentation { get; set; } public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation, IStudentRepository students, UntisReportCacheService cache) @@ -110,9 +111,9 @@ public partial class ClassTeacherCasesViewModel : ObservableObject 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, ""))); + item.LinkedDocumentation.Add(new DocumentationItem(doc, InvolvedNames(doc, nameById))); foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang)) - item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, ""))); + item.AvailableDocumentation.Add(new DocumentationItem(doc, InvolvedNames(doc, nameById))); foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date)) item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry)); @@ -125,6 +126,25 @@ public partial class ClassTeacherCasesViewModel : ObservableObject private void NotifyState() => OnPropertyChanged(nameof(HasCases)); + private static string InvolvedNames(Documentation doc, Dictionary nameById) => + string.Join(", ", doc.InvolvedStudentIds().Select(id => nameById.GetValueOrDefault(id, "")).Where(n => n != "")); + + private List VorgangStudentOptions(Vorgang vorgang) => + _rosterStudentOptions.Where(s => vorgang.StudentIds.Contains(s.Id)).ToList(); + + /// "Betrifft außerdem"-Kandidaten beim Bearbeiten eines Eintrags: die Beteiligten aller angezeigten + /// Vorgänge, in denen der Eintrag verknüpft oder verknüpfbar ist, plus die schon Beteiligten — + /// bewusst nicht der ganze Klassen-Roster, damit die Liste kurz bleibt. + private List CoStudentOptionsFor(Documentation doc) + { + var ids = Cases.Select(c => c.Model) + .Where(v => v.DocumentationIds.Contains(doc.Id) || v.StudentIds.Any(doc.Concerns)) + .SelectMany(v => v.StudentIds) + .Concat(doc.InvolvedStudentIds()) + .ToHashSet(); + return _rosterStudentOptions.Where(s => ids.Contains(s.Id)).ToList(); + } + /// Lädt Fehlzeiten und Klassenbucheinträge des laufenden Schuljahres (aus dem Untis-Cache, der /// kalte Bereich ist dort dauerhaft gespeichert) und leitet daraus den Stand je Schüler*in ab. /// Scheitert der Abruf (offline, Sitzung abgelaufen), gibt es eben nur die Standardformulierungen @@ -188,7 +208,7 @@ public partial class ClassTeacherCasesViewModel : ObservableObject _documentationDraftId = draft.Id; Documentation? result; - try { result = await OnEditDocumentation(option.Id, [option], draft); } + try { result = await OnEditDocumentation(option.Id, [option], VorgangStudentOptions(item.Model), draft); } finally { _documentationDraftId = null; } if (result is null) return; _documentation.Save(result); @@ -242,9 +262,9 @@ public partial class ClassTeacherCasesViewModel : ObservableObject 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 studentOptions = VorgangStudentOptions(item.Model); var defaultStudentId = item.Model.StudentIds.FirstOrDefault(); - var result = await OnEditDocumentation(defaultStudentId, studentOptions, null); + var result = await OnEditDocumentation(defaultStudentId, studentOptions, studentOptions, null); if (result is null) return; _documentation.Save(result); item.Model.DocumentationIds.Add(result.Id); @@ -259,19 +279,20 @@ public partial class ClassTeacherCasesViewModel : ObservableObject private async Task EditDocumentation(DocumentationItem doc) { if (OnEditDocumentation is null) return; - var result = await OnEditDocumentation(doc.Model.StudentId, _rosterStudentOptions, doc.Model); + var result = await OnEditDocumentation(doc.Model.StudentId, _rosterStudentOptions, + CoStudentOptionsFor(doc.Model), doc.Model); if (result is null) return; _documentation.Save(result); 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 - /// ). + /// Dokumentation der Vorgangs-Schüler*innen (als Haupt- oder weitere Beteiligte), 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(); + return allDocs.Where(d => vorgang.StudentIds.Any(d.Concerns) && !linkedIds.Contains(d.Id)).ToList(); } private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc) diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index ce57eeb..f36c5b3 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -464,7 +464,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet(); return all .Where(d => !d.IsDeleted && - (matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) && + (d.InvolvedStudentIds().Any(matchedIds.Contains) || IsUnassignedClassDocumentation(d)) && d.Date >= start && d.Date <= end) .Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter)) .OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date) @@ -477,8 +477,10 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject if (string.IsNullOrWhiteSpace(studentFilter)) return true; if (IsUnassignedClassDocumentation(d)) return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter)); - var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName; - return name is not null && NameContainsSearchTerm(name, studentFilter); + // Gemeinsame Einträge (weitere Beteiligte) passen, sobald eine der beteiligten Personen passt. + return d.InvolvedStudentIds() + .Select(id => rosterMatches.FirstOrDefault(m => m.StudentId == id).DisplayName) + .Any(name => name is not null && NameContainsSearchTerm(name, studentFilter)); } private static bool IsUnassignedClassDocumentation(Documentation d) => @@ -495,8 +497,13 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches) { if (d.StudentId != Guid.Empty) - return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName - ?? "Unbekannter Schülerbezug"; + { + var names = d.InvolvedStudentIds() + .Select(id => rosterMatches.FirstOrDefault(m => m.StudentId == id).DisplayName) + .Where(n => n is not null) + .ToList(); + return names.Count > 0 ? string.Join(", ", names) : "Unbekannter Schülerbezug"; + } return d.Participants.Count > 0 ? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}" : "Ohne Schülerbezug"; diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 175f2d3..cb164b2 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -1179,7 +1179,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject .Where(s => s is not null) .Select(s => s!.Id) .ToHashSet(); - var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList(); + var docs = _documentation.GetAll() + .Where(d => !d.IsDeleted && d.InvolvedStudentIds().Any(matchedIds.Contains)).ToList(); OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft); OwnDocumentationCriticalCount = docs.Count(d => d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase))); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs index a955461..d5880e2 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs @@ -296,6 +296,7 @@ public partial class GroupOverviewViewModel : ObservableObject { DraftDocumentationCount = _students.GetByGroup(_groupId) .SelectMany(s => _documentation.GetByStudent(s.Id)) + .DistinctBy(d => d.Id) .Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId)); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index db33e10..983d2fd 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -313,7 +313,8 @@ public partial class SeatingPlanTabViewModel : ObservableObject } entries.TryGetValue(studentId, out var entry); var tags = _documentation?.GetByStudent(studentId) - .FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id)?.Tags ?? []; + .FirstOrDefault(d => d.IsDraft && d.StudentId == studentId && d.ParticipationSessionId == SelectedSession.Id) + ?.Tags ?? []; seat.SetLessonData(entry, tags); } } @@ -340,8 +341,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject seat.SelectedOption.StudentId is not Guid studentId) return; var session = EnsureTodaySession(); if (session is null) return; + // Nur Entwürfe mit dieser Person als Hauptperson — ein gemeinsamer Eintrag (weitere Beteiligte) + // ist keine persönliche Sitzplan-Notiz. var draft = _documentation.GetByStudent(studentId) - .FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == session.Id); + .FirstOrDefault(d => d.IsDraft && d.StudentId == studentId && d.ParticipationSessionId == session.Id); if (draft is null) { draft = new Documentation diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Privacy.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Privacy.cs index 682de6a..7447703 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Privacy.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Privacy.cs @@ -36,8 +36,8 @@ public partial class SettingsViewModel var cutoff = _privacy.RetentionCutoff(); foreach (var d in _documentation.GetAll().Where(d => d.CreatedAt < cutoff)) { - var student = _students.GetById(d.StudentId); - ExpiredDocuments.Add(new ExpiredDocumentItem(d, student?.FullName ?? "?")); + var names = d.InvolvedStudentIds().Select(id => _students.GetById(id)?.FullName ?? "?").ToList(); + ExpiredDocuments.Add(new ExpiredDocumentItem(d, names.Count > 0 ? string.Join("; ", names) : "?")); } } diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs index 9757cc9..ae4c03b 100644 --- a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs @@ -104,6 +104,12 @@ public partial class DocumentationDialogViewModel : ObservableObject [ObservableProperty] private StudentOption? _selectedStudent; [ObservableProperty] private string _studentError = ""; + // Weitere Beteiligte (Documentation.AdditionalStudentIds) — nur angeboten, wenn der Aufrufer + // Kandidaten mitgibt (z.B. die übrigen Schüler*innen eines Vorgangs). Die Hauptperson steht + // mit in der Liste, wird aber ausgeblendet (CoStudentChoice.IsPrimary). + public List CoStudents { get; } + public bool HasCoStudents => CoStudents.Any(c => !c.IsPrimary); + [ObservableProperty] private string _typeName = DocumentationTypeDisplay.Options[0]; [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _title = ""; @@ -163,8 +169,11 @@ public partial class DocumentationDialogViewModel : ObservableObject public Documentation? Result { get; private set; } + /// Kandidaten für "Betrifft außerdem" — ohne Angabe bleiben + /// bestehende weitere Beteiligte eines bearbeiteten Eintrags unverändert erhalten. public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage, - List? studentOptions = null, Guid? contextGroupId = null, bool isNewDraft = false) + List? studentOptions = null, Guid? contextGroupId = null, bool isNewDraft = false, + List? coStudentOptions = null) { _studentId = studentId; _editing = editing; @@ -174,8 +183,17 @@ public partial class DocumentationDialogViewModel : ObservableObject _attachmentStorage = attachmentStorage; _contextGroupId = contextGroupId; StudentOptions = studentOptions ?? []; + CoStudents = (coStudentOptions ?? []) + .Where(o => o.Id != Guid.Empty) + .DistinctBy(o => o.Id) + .Select(o => new CoStudentChoice(o.Id, o.Name) + { + IsSelected = editing?.AdditionalStudentIds.Contains(o.Id) == true, + }) + .ToList(); if (CanPickStudent) SelectedStudent = StudentOptions.FirstOrDefault(s => s.Id == studentId); + UpdatePrimaryCoStudent(); if (editing is null) return; TypeName = DocumentationTypeDisplay.Label(editing.Type); @@ -212,6 +230,22 @@ public partial class DocumentationDialogViewModel : ObservableObject foreach (var tag in editing.Tags) Tags.Add(tag); } + // Beim Bearbeiten bleibt die Hauptperson die des bestehenden Eintrags (Save() übernimmt die + // Auswahl dann nicht), sonst folgt sie der Bezug-Auswahl. + private Guid PrimaryStudentId => + _editing is not null && !_isNewDraft ? _editing.StudentId + : CanPickStudent ? SelectedStudent?.Id ?? Guid.Empty + : _editing?.StudentId ?? _studentId; + + partial void OnSelectedStudentChanged(StudentOption? value) => UpdatePrimaryCoStudent(); + + private void UpdatePrimaryCoStudent() + { + var primary = PrimaryStudentId; + foreach (var c in CoStudents) c.IsPrimary = c.Id == primary; + OnPropertyChanged(nameof(HasCoStudents)); + } + partial void OnTypeNameChanged(string value) { OnPropertyChanged(nameof(IsConversation)); @@ -348,6 +382,14 @@ public partial class DocumentationDialogViewModel : ObservableObject var type = DocumentationTypeDisplay.FromLabel(TypeName); var effectiveStudentId = CanPickStudent ? SelectedStudent!.Id : _studentId; Result = _editing ?? new Documentation { StudentId = effectiveStudentId, GroupId = _contextGroupId }; + // Nicht angebotene weitere Beteiligte bleiben erhalten (z.B. beim Bearbeiten aus dem Schülerdatensatz, + // wo keine Kandidaten bekannt sind), angebotene folgen der Auswahl. + var offered = CoStudents.Select(c => c.Id).ToHashSet(); + Result.AdditionalStudentIds = Result.AdditionalStudentIds.Where(id => !offered.Contains(id)) + .Concat(CoStudents.Where(c => c.IsSelected).Select(c => c.Id)) + .Where(id => id != Result.StudentId && id != Guid.Empty) + .Distinct() + .ToList(); Result.Type = type; Result.Date = date; Result.Title = Title.Trim(); @@ -413,6 +455,14 @@ public partial class DocumentationDialogViewModel : ObservableObject } } +public partial class CoStudentChoice(Guid id, string name) : ObservableObject +{ + public Guid Id { get; } = id; + public string Name { get; } = name; + [ObservableProperty] private bool _isSelected; + [ObservableProperty] private bool _isPrimary; +} + public class AttachmentItem(string storageId, string fileName, long sizeBytes, DateTime uploadedAt) { public const string HazardAssessmentSuffix = ".gbu.json"; @@ -458,6 +508,10 @@ public partial class DocumentationItem : ObservableObject public string OtherGroupLabel { get; } /// Dimmt Einträge aus anderen Lerngruppen im Gruppen-Tab, ohne sie zu verstecken. public double ContentOpacity => IsOwnGroup ? 1.0 : 0.55; + /// Nur im Schülerdatensatz gefüllt: die übrigen Beteiligten eines gemeinsamen Eintrags + /// (Documentation.AdditionalStudentIds bzw. Hauptperson), damit er dort als geteilt erkennbar ist. + public string SharedWith { get; init; } = ""; + public bool IsShared => SharedWith != ""; [ObservableProperty] private bool _isRevealed; diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index 9bada05..315d6d4 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -334,7 +334,12 @@ public partial class StudentDetailViewModel : ObservableObject if (Student is null) return; Documentation.Clear(); foreach (var d in _docs.GetByStudent(Student.Id)) - Documentation.Add(new DocumentationItem(d)); + { + var others = d.InvolvedStudentIds().Where(id => id != Student.Id) + .Select(id => _students.GetById(id)?.FullName) + .Where(n => !string.IsNullOrEmpty(n)); + Documentation.Add(new DocumentationItem(d) { SharedWith = string.Join("; ", others) }); + } } [RelayCommand] diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs index 4739e6d..3175c9b 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs @@ -39,7 +39,8 @@ public partial class StudentVorgaengeViewModel : ObservableObject public Func, Vorgang?, Task>? OnEditVorgang { get; set; } public Func>? OnConfirmDeleteVorgang { get; set; } - public Func>? OnEditDocumentation { get; set; } + /// Parameter: Hauptperson, Kandidaten für "Betrifft außerdem", bestehender Eintrag. + public Func, Documentation?, Task>? OnEditDocumentation { get; set; } /// Rückfrage vor dem automatischen Anlegen eines Vorgangs aus einer Dashboard-Warnung heraus /// (siehe ) — Parameter ist das gesuchte Tag. public Func>? OnConfirmCreateForWarning { get; set; } @@ -89,8 +90,8 @@ public partial class StudentVorgaengeViewModel : ObservableObject IEnumerable vorgaenge = _vorgaenge.GetByStudent(studentId); if (OnlyOpen) vorgaenge = vorgaenge.Where(v => v.Status == VorgangStatus.Open); - // Nur die Dokumentation dieses einen Schülers (nicht GetAll()) — dieses Panel ist die Sicht - // "von diesem Schüler aus", nicht Vorgangsverwaltung für mehrere Beteiligte. + // Nur die Dokumentation, die diesen Schüler betrifft (nicht GetAll()) — dieses Panel ist die + // Sicht "von diesem Schüler aus"; gemeinsame Einträge mit weiteren Beteiligten zählen dazu. var ownDocs = _documentation.GetByStudent(studentId); foreach (var vorgang in vorgaenge.OrderByDescending(v => v.UpdatedAt)) @@ -107,9 +108,9 @@ public partial class StudentVorgaengeViewModel : ObservableObject var linkedIds = vorgang.DocumentationIds.ToHashSet(); foreach (var doc in ownDocs.Where(d => linkedIds.Contains(d.Id))) - item.LinkedDocumentation.Add(new DocumentationItem(doc, _studentName)); + item.LinkedDocumentation.Add(new DocumentationItem(doc, InvolvedNames(doc))); foreach (var doc in ClassTeacherCasesViewModel.FilterAvailableDocumentation(ownDocs, vorgang)) - item.AvailableDocumentation.Add(new DocumentationItem(doc, _studentName)); + item.AvailableDocumentation.Add(new DocumentationItem(doc, InvolvedNames(doc))); foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date)) item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry)); @@ -118,6 +119,27 @@ public partial class StudentVorgaengeViewModel : ObservableObject OnPropertyChanged(nameof(HasCases)); } + private string StudentName(Guid id) => id == _studentId ? _studentName : _students.GetById(id)?.FullName ?? ""; + + private string InvolvedNames(Documentation doc) => + string.Join(", ", doc.InvolvedStudentIds().Select(StudentName).Where(n => n != "")); + + private List VorgangStudentOptions(Vorgang vorgang) => vorgang.StudentIds + .Select(id => new StudentOption(id, StudentName(id))) + .Where(o => o.Name != "") + .ToList(); + + /// "Betrifft außerdem"-Kandidaten beim Bearbeiten: Beteiligte der angezeigten Vorgänge, in denen der + /// Eintrag verknüpft oder verknüpfbar ist, plus die schon Beteiligten. + private List CoStudentOptionsFor(Documentation doc) => Cases.Select(c => c.Model) + .Where(v => v.DocumentationIds.Contains(doc.Id) || v.StudentIds.Any(doc.Concerns)) + .SelectMany(v => v.StudentIds) + .Concat(doc.InvolvedStudentIds()) + .Distinct() + .Select(id => new StudentOption(id, StudentName(id))) + .Where(o => o.Name != "") + .ToList(); + [RelayCommand] private async Task AddVorgang() { @@ -161,7 +183,7 @@ public partial class StudentVorgaengeViewModel : ObservableObject private async Task CreateAndLinkDocumentation(VorgangItem? item) { if (item is null || OnEditDocumentation is null) return; - var result = await OnEditDocumentation(_studentId, null); + var result = await OnEditDocumentation(_studentId, VorgangStudentOptions(item.Model), null); if (result is null) return; _documentation.Save(result); item.Model.DocumentationIds.Add(result.Id); @@ -179,7 +201,7 @@ public partial class StudentVorgaengeViewModel : ObservableObject if (draft is null) return; _documentationDraftId = draft.Id; Documentation? result; - try { result = await OnEditDocumentation(_studentId, draft); } + try { result = await OnEditDocumentation(_studentId, VorgangStudentOptions(item.Model), draft); } finally { _documentationDraftId = null; } if (result is null) return; _documentation.Save(result); @@ -193,7 +215,7 @@ public partial class StudentVorgaengeViewModel : ObservableObject private async Task EditDocumentation(DocumentationItem doc) { if (OnEditDocumentation is null) return; - var result = await OnEditDocumentation(doc.Model.StudentId, doc.Model); + var result = await OnEditDocumentation(doc.Model.StudentId, CoStudentOptionsFor(doc.Model), doc.Model); if (result is null) return; _documentation.Save(result); Load(_studentId); diff --git a/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs index 898eaff..6ea0d33 100644 --- a/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs @@ -106,7 +106,7 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject // 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(); + localDocs.Where(d => d.Concerns(studentId) && d.Date == date).ToList(); foreach (var (entry, date) in ordered) { @@ -128,15 +128,20 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject }); } + // WebUntis-Klassenbucheinträge sind personenbezogen — ein gemeinsamer lokaler Eintrag + // (weitere Beteiligte) wird deshalb je beteiligter Person einzeln abgeglichen. 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)); + foreach (var studentId in doc.InvolvedStudentIds()) + { + var coveredByReport = Rows.Any(r => r.AssignedStudent?.Id == studentId && r.Date == doc.Date); + if (coveredByReport) continue; + var student = ownStudents.FirstOrDefault(s => s.Id == 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); diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs index fd71dff..17db53b 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs @@ -51,7 +51,8 @@ public partial class ClassTeacherCasesView : UserControl } private async Task ShowDocumentationDialog( - Guid defaultStudentId, List studentOptions, Documentation? editing) + Guid defaultStudentId, List studentOptions, List coStudentOptions, + Documentation? editing) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return null; @@ -59,7 +60,8 @@ public partial class ClassTeacherCasesView : UserControl var isNewDraft = editing is not null && (DataContext as ClassTeacherCasesViewModel)?.IsNewDocumentationDraft(editing) == true; var vm = new DocumentationDialogViewModel(defaultStudentId, editing, - App.Services.GetRequiredService(), studentOptions, isNewDraft: isNewDraft); + App.Services.GetRequiredService(), studentOptions, isNewDraft: isNewDraft, + coStudentOptions: coStudentOptions); var dialog = new DocumentationDialog { DataContext = vm }; var saved = await dialog.ShowDialog(owner); if (!saved) vm.DiscardUnsavedAttachments(); diff --git a/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml b/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml index 260e8d5..3eed87d 100644 --- a/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml +++ b/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml @@ -21,6 +21,20 @@ IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml index aa9a4a6..bc90c72 100644 --- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml +++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml @@ -299,6 +299,8 @@ + diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs index e9b3031..bf6583d 100644 --- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs +++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs @@ -105,7 +105,8 @@ public partial class StudentDetailView : UserControl var info = new ConfirmDialogInfo { Title = "Eintrag löschen?", - Message = $"\"{item.Model.Title}\" wird als gelöscht markiert und nicht mehr angezeigt.", + Message = $"\"{item.Model.Title}\" wird als gelöscht markiert und nicht mehr angezeigt." + + (item.IsShared ? $" Der Eintrag verschwindet auch bei {item.SharedWith}." : ""), ConfirmText = "Löschen", }; var dialog = new ConfirmDialog { DataContext = info }; diff --git a/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs b/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs index e14f488..2d94bdf 100644 --- a/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs @@ -52,7 +52,8 @@ public partial class StudentVorgaengeTabView : UserControl return owner is not null && await dialog.ShowDialog(owner); } - private async Task ShowDocumentationDialog(Guid studentId, Documentation? editing) + private async Task ShowDocumentationDialog( + Guid studentId, List coStudentOptions, Documentation? editing) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return null; @@ -60,7 +61,8 @@ public partial class StudentVorgaengeTabView : UserControl var isNewDraft = editing is not null && (DataContext as StudentVorgaengeViewModel)?.IsNewDocumentationDraft(editing) == true; var vm = new DocumentationDialogViewModel(studentId, editing, - App.Services.GetRequiredService(), isNewDraft: isNewDraft); + App.Services.GetRequiredService(), isNewDraft: isNewDraft, + coStudentOptions: coStudentOptions); var dialog = new DocumentationDialog { DataContext = vm }; var saved = await dialog.ShowDialog(owner); if (!saved) vm.DiscardUnsavedAttachments(); diff --git a/TODO.md b/TODO.md index 82b1892..a892f02 100644 --- a/TODO.md +++ b/TODO.md @@ -2050,6 +2050,31 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk `ClassTeacherStudentFacts.TemplateContext`). Vorlagen im Klassenlehrer-Tab beziehen sich auf die/den erste/n Beteiligte/n des Vorgangs, der Dokumentations-Dialog bietet dann nur diese Person an (sonst passten die eingesetzten Zahlen nicht zur gewählten Person). +- [x] **Vorgang mit mehreren Beteiligten — gemeinsame Dokumentation (September 2026):** Nutzer- + Feedback: ein Vorgang kann mehrere Schüler*innen umfassen, ein `Documentation`-Eintrag gehörte aber + immer genau einer Person — wer einen gemeinsamen Eintrag (z.B. Gespräch mit beiden + Streitparteien) bei jeder Person im Dokumentations-Tab sehen wollte, musste ihn mehrfach mit + identischem Inhalt anlegen. **Nutzer-Entscheidung: ein Eintrag, mehrere Personen** (statt + automatischer unabhängiger Kopien). `Documentation` bekommt `AdditionalStudentIds` (weitere + Beteiligte; `StudentId` bleibt Hauptperson, damit bestehende Daten, Sync-Events und Gruppeneinträge + mit `Guid.Empty` unverändert gelten) sowie `Concerns(id)`/`InvolvedStudentIds()` als einzige Regel + für "betrifft diese Person". `DocumentationRepository.GetByStudent`/`GetByStudentAndType` filtern + danach (im Speicher statt als LiteDB-Ausdruck), dadurch erscheint der Eintrag automatisch im + Dokumentations-Tab jeder beteiligten Person, im schülerbezogenen Vorgänge-Tab und im + Personendaten-Export. `GetReferenceSummary` zählt gemeinsame Einträge mit, damit eine beteiligte + Person nicht hart gelöscht werden kann (sonst bliebe eine verwaiste ID zurück). + Der `DocumentationDialog` hat einen neuen Bereich **"Betrifft außerdem"** (Checkboxen, optionaler + Parameter `coStudentOptions`, Hauptperson ausgeblendet). Angeboten wird er nur aus den beiden + Vorgänge-Tabs heraus, mit den Beteiligten des Vorgangs (beim Bearbeiten: Beteiligte aller + angezeigten Vorgänge, in denen der Eintrag verknüpft oder verknüpfbar ist). Beim Bearbeiten ohne + Kandidatenliste (z.B. aus dem Schülerdatensatz) bleiben bestehende weitere Beteiligte erhalten, + abgewählt werden nur angebotene. Im Schülerdatensatz zeigt ein gemeinsamer Eintrag + "👥 gemeinsam mit …", und die Lösch-Rückfrage weist darauf hin, dass er auch bei den anderen + verschwindet. Mitgezogen: Klassenlehrer-Dokumentationsliste und -Zähler, "verknüpfbare" + Dokumentation im Vorgang, WebUntis-Abgleich (gemeinsamer Eintrag wird je beteiligter Person + abgeglichen, da WebUntis-Klassenbucheinträge personenbezogen sind), Löschfristen-Liste (alle Namen). + **Bewusst unverändert:** Sitzplan-Entwürfe bleiben persönliche Notizen (nur Hauptperson), der + Gruppen-Dokumentations-Tab bietet (noch) keine weiteren Beteiligten an. **Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026):** Nutzer-Feedback: beim Planen der nächsten Woche musste nach jedem kurzen Abstecher in einen anderen Bereich der Sidebar wieder