Gemeinsame Dokumentation für mehrere Beteiligte eines Vorgangs
CI / build-and-test (push) Canceled after 0s

Ein Dokumentationseintrag kann jetzt weitere Schüler*innen betreffen
(Documentation.AdditionalStudentIds, StudentId bleibt Hauptperson). Der
Eintrag existiert nur einmal, erscheint aber im Dokumentations-Tab jeder
beteiligten Person. Der Dokumentations-Dialog bietet in beiden
Vorgänge-Tabs "Betrifft außerdem" mit den Beteiligten des Vorgangs an.
Klassenlehrer-Listen, WebUntis-Abgleich, Löschfristen und
Referenzzählung berücksichtigen die weiteren Beteiligten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 08:43:24 +02:00
1 parent e091b5c8de
commit 9a84bbaa38
23 files changed
+396 -49

No files matched your search

+15
View File
@@ -3,7 +3,14 @@ namespace LehrerApp.Core.Models;
public class Documentation : IHasAttachments
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Hauptperson des Eintrags (bei Gruppeneinträgen <see cref="Guid.Empty"/>).</summary>
public Guid StudentId { get; set; }
/// <summary>
/// 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 <see cref="Concerns"/>).
/// Enthält nie <see cref="StudentId"/> selbst.
/// </summary>
public List<Guid> AdditionalStudentIds { get; set; } = [];
public Guid? GroupId { get; set; }
/// <summary>Unterrichtssitzung, in der die Beobachtung entstanden ist.</summary>
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<Guid> InvolvedStudentIds() =>
(StudentId == Guid.Empty ? [] : new[] { StudentId }).Concat(AdditionalStudentIds);
}
public class AbsenceData
{
+35
View File
@@ -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<InvalidOperationException>(() => students.Delete(ben.Id));
}
}
@@ -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<Documentation> 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<Documentation> 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<Documentation> GetAll() =>
db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
@@ -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]
@@ -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);
}
}
+2 -2
View File
@@ -320,9 +320,9 @@ public class FakeDocumentation : IDocumentationRepository
private readonly List<Documentation> _all = [];
public void Add(Documentation d) => _all.Add(d);
public List<Documentation> GetByStudent(Guid studentId) =>
_all.Where(d => d.StudentId == studentId && !d.IsDeleted).ToList();
_all.Where(d => d.Concerns(studentId) && !d.IsDeleted).ToList();
public List<Documentation> 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<Documentation> 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; }
@@ -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<StudentOption> offered = [];
vm.OnEditDocumentation = (studentId, coStudents, _) =>
{
offered = coStudents;
return Task.FromResult<Documentation?>(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);
@@ -42,7 +42,8 @@ public partial class ClassTeacherCasesViewModel : ObservableObject
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
/// Parameter: Hauptperson, Bezug-Auswahl, Kandidaten für "Betrifft außerdem", bestehender Eintrag.
public Func<Guid, List<StudentOption>, List<StudentOption>, Documentation?, Task<Documentation?>>? 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<Guid, string> nameById) =>
string.Join(", ", doc.InvolvedStudentIds().Select(id => nameById.GetValueOrDefault(id, "")).Where(n => n != ""));
private List<StudentOption> 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<StudentOption> 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
/// <see cref="ClassTeacherRosterRow.Build"/>).
/// 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 <see cref="ClassTeacherRosterRow.Build"/>).
public static List<Documentation> FilterAvailableDocumentation(IReadOnlyList<Documentation> 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)
@@ -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";
@@ -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)));
@@ -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));
}
@@ -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
@@ -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) : "?"));
}
}
@@ -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<CoStudentChoice> 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; }
/// <param name="coStudentOptions">Kandidaten für "Betrifft außerdem" — ohne Angabe bleiben
/// bestehende weitere Beteiligte eines bearbeiteten Eintrags unverändert erhalten.</param>
public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage,
List<StudentOption>? studentOptions = null, Guid? contextGroupId = null, bool isNewDraft = false)
List<StudentOption>? studentOptions = null, Guid? contextGroupId = null, bool isNewDraft = false,
List<StudentOption>? 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;
@@ -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]
@@ -39,7 +39,8 @@ public partial class StudentVorgaengeViewModel : ObservableObject
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
public Func<Guid, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
/// Parameter: Hauptperson, Kandidaten für "Betrifft außerdem", bestehender Eintrag.
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
/// Rückfrage vor dem automatischen Anlegen eines Vorgangs aus einer Dashboard-Warnung heraus
/// (siehe <see cref="HandleWarningNavigation"/>) — Parameter ist das gesuchte Tag.
public Func<string, Task<bool>>? OnConfirmCreateForWarning { get; set; }
@@ -89,8 +90,8 @@ public partial class StudentVorgaengeViewModel : ObservableObject
IEnumerable<Vorgang> 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<StudentOption> 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<StudentOption> 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);
@@ -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<Documentation> 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);
@@ -51,7 +51,8 @@ public partial class ClassTeacherCasesView : UserControl
}
private async Task<Documentation?> ShowDocumentationDialog(
Guid defaultStudentId, List<StudentOption> studentOptions, Documentation? editing)
Guid defaultStudentId, List<StudentOption> studentOptions, List<StudentOption> 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<IAttachmentStorage>(), studentOptions, isNewDraft: isNewDraft);
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, isNewDraft: isNewDraft,
coStudentOptions: coStudentOptions);
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
@@ -21,6 +21,20 @@
IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding HasCoStudents}">
<TextBlock Text="Betrifft außerdem" FontSize="12" Opacity="0.7"/>
<TextBlock Text="Der Eintrag wird nur einmal gespeichert, erscheint aber auch in der Dokumentation der hier angehakten Schüler*innen."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding CoStudents}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CoStudentChoice">
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsSelected}"
IsVisible="{Binding !IsPrimary}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<Grid ColumnDefinitions="*,12,140">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
@@ -299,6 +299,8 @@
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.55"
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="📎 Anhang" FontSize="11" Opacity="0.55" IsVisible="{Binding HasAttachments}"/>
<TextBlock Text="{Binding SharedWith, StringFormat='👥 gemeinsam mit {0}'}" FontSize="11"
Opacity="0.55" TextWrapping="Wrap" IsVisible="{Binding IsShared}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TagChips}" IsVisible="{Binding IsRevealed}">
<ItemsControl.ItemsPanel>
@@ -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 };
@@ -52,7 +52,8 @@ public partial class StudentVorgaengeTabView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<Documentation?> ShowDocumentationDialog(Guid studentId, Documentation? editing)
private async Task<Documentation?> ShowDocumentationDialog(
Guid studentId, List<StudentOption> 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<IAttachmentStorage>(), isNewDraft: isNewDraft);
App.Services.GetRequiredService<IAttachmentStorage>(), isNewDraft: isNewDraft,
coStudentOptions: coStudentOptions);
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
+25
View File
@@ -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