Gemeinsame Dokumentation für mehrere Beteiligte eines Vorgangs
CI / build-and-test (push) Canceled after 0s
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:
1 parent
e091b5c8de
commit
9a84bbaa38
23 files changed
+396
-49
No files matched your search
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user