"Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
CI / build-and-test (push) Canceled after 0s

Neuer 4. Tab in der Klassenlehreransicht: bündelt Titel, Beschreibung,
Schlagwörter, verknüpfte Dokumentation und angeheftete (eingefrorene)
WebUntis-Klassenbucheinträge zu einem laufenden Problem mit einer/einem
oder mehreren Schüler*innen. Anheften direkt aus dem Klassenbuch-Tab per
Rechtsklick. Sync-fähig nach dem bestehenden Documentation-Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 22:42:06 +02:00
co-authored by Claude Sonnet 5
parent 987b49e761
commit 15463bc292
23 changed files with 1191 additions and 5 deletions
@@ -200,6 +200,15 @@ public interface IDocumentationRepository
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2). /// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
void HardDelete(Guid id); void HardDelete(Guid id);
} }
public interface IVorgangRepository
{
List<Vorgang> GetAll();
List<Vorgang> GetByStudent(Guid studentId);
Vorgang? GetById(Guid id);
void Save(Vorgang vorgang);
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
void Delete(Guid id);
}
public interface IWorkTaskRepository public interface IWorkTaskRepository
{ {
List<WorkTask> GetByStatus(WorkTaskStatus status); List<WorkTask> GetByStatus(WorkTaskStatus status);
+49
View File
@@ -0,0 +1,49 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
/// </summary>
public class Vorgang
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public List<Guid> StudentIds { get; set; } = [];
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
public List<string> Tags { get; set; } = [];
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
public List<Guid> DocumentationIds { get; set; } = [];
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
public enum VorgangStatus { Open, Closed }
/// <summary>
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
/// </summary>
public class VorgangClassRegisterEntry
{
public DateOnly Date { get; set; }
public string StudentName { get; set; } = "";
public string? Subject { get; set; }
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
}
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
var call = Assert.Single(calls); var call = Assert.Single(calls);
Assert.Equal((nameof(Documentation), "Delete"), call); Assert.Equal((nameof(Documentation), "Delete"), call);
} }
[Fact]
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Konflikt" };
repo.Save(vorgang);
var calls = new List<(string EntityType, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
repo.Delete(vorgang.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
Assert.True(((Vorgang)call.Payload!).IsDeleted);
}
} }
+61
View File
@@ -715,6 +715,67 @@ public sealed class RepositoryTests
Assert.Null(storage.OpenRead(attachmentId)); Assert.Null(storage.OpenRead(attachmentId));
} }
// ── VorgangRepository ─────────────────────────────────────────────────────
[Fact]
public void VorgangRepository_Save_SetztUpdatedAt()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Schuleschwänzen Max" };
repo.Save(vorgang);
var raw = db.Vorgaenge.FindById(vorgang.Id);
Assert.NotNull(raw);
Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5);
}
[Fact]
public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var other = Guid.NewGuid();
var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] };
var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] };
repo.Save(shared);
repo.Save(unrelated);
Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id));
Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id));
Assert.Empty(repo.GetByStudent(Guid.NewGuid()));
}
[Fact]
public void VorgangRepository_Delete_MarkiertNurAlsGeloescht()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" };
repo.Save(vorgang);
repo.Delete(vorgang.Id);
Assert.Empty(repo.GetAll());
Assert.Null(repo.GetById(vorgang.Id));
var raw = db.Vorgaenge.FindById(vorgang.Id);
Assert.NotNull(raw);
Assert.True(raw!.IsDeleted);
Assert.NotNull(raw.DeletedAt);
}
[Fact]
public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
Assert.Null(repo.GetById(Guid.NewGuid()));
}
// ── LessonRepository ────────────────────────────────────────────────────── // ── LessonRepository ──────────────────────────────────────────────────────
[Fact] [Fact]
+1
View File
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units"); public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons"); public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation"); public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks"); public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks"); public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries"); public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
@@ -431,6 +431,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
} }
} }
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
{
public List<Vorgang> GetAll() =>
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
public List<Vorgang> GetByStudent(Guid studentId) =>
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
public Vorgang? GetById(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
}
public void Save(Vorgang vorgang)
{
vorgang.UpdatedAt = DateTime.UtcNow;
db.Vorgaenge.Upsert(vorgang);
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
}
public void Delete(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
if (vorgang is null) return;
vorgang.IsDeleted = true;
vorgang.DeletedAt = DateTime.UtcNow;
db.Vorgaenge.Update(vorgang);
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
// (gleiches Muster wie DocumentationRepository.Delete).
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
}
}
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
{ {
public List<WorkTask> GetByStatus(WorkTaskStatus s) => public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
@@ -840,4 +840,53 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(["Ada"], result.Select(d => d.Title)); Assert.Equal(["Ada"], result.Select(d => d.Title));
} }
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
[Fact]
public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile()
{
var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben",
"mueller", "Fehlende HA", "Negativ", "Buch vergessen");
var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row);
Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date);
Assert.Equal("Schmidt Ben", snapshot.StudentName);
Assert.Equal("Deu", snapshot.Subject);
Assert.Equal("mueller", snapshot.TeacherUsername);
Assert.Equal("Fehlende HA", snapshot.CategoryName);
Assert.Equal("Negativ", snapshot.CategoryGroup);
Assert.Equal("Buch vergessen", snapshot.Text);
}
[Fact]
public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler()
{
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var fremd = Guid.NewGuid();
var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" };
var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" };
var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" };
var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" };
var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] };
var allDocs = new List<Documentation> { linked, unlinked, otherStudentInCase, unrelated };
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang);
Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title));
}
[Fact]
public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege()
{
var ada = Guid.NewGuid();
var doc = new Documentation { StudentId = ada, Title = "Verknüpft" };
var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] };
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang);
Assert.Empty(result);
}
} }
+2
View File
@@ -162,6 +162,7 @@ public static class AppBootstrapper
services.AddSingleton<IUnitRepository, UnitRepository>(); services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>(); services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>(); services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IVorgangRepository, VorgangRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>(); services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>(); services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>(); services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
@@ -315,6 +316,7 @@ public static class AppBootstrapper
services.AddSingleton<WorkloadEvaluationViewModel>(); services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>(); services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>(); services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherCasesViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>(); services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>(); services.AddSingleton<ExamsOverviewViewModel>();
@@ -0,0 +1,235 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>
/// "Vorgänge"-Tab der Klassenlehreransicht: Fallmappen für konkrete, laufende Probleme mit einer/
/// einem oder mehreren Schüler*innen der Klasse, die Dokumentation und (eingefrorene) WebUntis-
/// Klassenbucheinträge bündeln. Löst wie <see cref="ClassTeacherDetailsViewModel"/> die Untis-
/// Roster-Namen der Klasse auf lokale <see cref="Student"/>-Datensätze auf (bewusst ein eigener,
/// nicht geteilter Abgleich — dasselbe kleine Muster steckt schon zweimal im Code, siehe
/// <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>, eine gemeinsame Abstraktion dafür ist
/// hier nicht Teil der Aufgabe).
/// </summary>
public partial class ClassTeacherCasesViewModel : ObservableObject
{
private readonly IVorgangRepository _vorgaenge;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly UntisReportCacheService _cache;
private string _className = "";
private List<StudentOption> _rosterStudentOptions = [];
public ObservableCollection<VorgangItem> Cases { get; } = [];
[ObservableProperty] private bool _onlyOpen = true;
[ObservableProperty] private bool _busy;
[ObservableProperty] private string _status = "";
public bool HasCases => Cases.Count > 0;
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; }
public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
IStudentRepository students, UntisReportCacheService cache)
{
_vorgaenge = vorgaenge;
_documentation = documentation;
_students = students;
_cache = cache;
}
public void Initialize(string className)
{
_className = className;
Cases.Clear();
Status = "Wird geladen…";
NotifyState();
_ = LoadInternal();
}
partial void OnOnlyOpenChanged(bool value) => _ = LoadInternal();
[RelayCommand]
private Task Refresh() => LoadInternal();
private async Task LoadInternal()
{
if (string.IsNullOrWhiteSpace(_className)) return;
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt, sonst klappt eine gerade
// aufgeklappte Karte bei jeder Verknüpfungs-/Status-Aktion (die intern neu lädt) wieder zu.
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
Busy = true;
Cases.Clear();
NotifyState();
try
{
var roster = await _cache.GetStudentRosterAsync(_className);
var localStudents = _students.GetAll();
var matches = roster
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
.DistinctBy(x => x.StudentId)
.ToList();
_rosterStudentOptions = matches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var rosterIds = matches.Select(m => m.StudentId).ToHashSet();
var nameById = matches.ToDictionary(m => m.StudentId, m => m.DisplayName);
var relevant = _vorgaenge.GetAll().Where(v => v.StudentIds.Any(rosterIds.Contains));
if (OnlyOpen) relevant = relevant.Where(v => v.Status == VorgangStatus.Open);
var allDocs = _documentation.GetAll();
foreach (var vorgang in relevant.OrderByDescending(v => v.UpdatedAt))
{
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
.Where(n => n != "");
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
UnlinkDocumentation, RemoveClassRegisterEntry) { IsRevealed = revealedIds.Contains(vorgang.Id) };
var linkedIds = vorgang.DocumentationIds.ToHashSet();
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
item.LinkedDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang))
item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
Cases.Add(item);
}
Status = OnlyOpen ? $"{Cases.Count} offene Vorgänge" : $"{Cases.Count} Vorgänge";
}
finally { Busy = false; NotifyState(); }
}
private void NotifyState() => OnPropertyChanged(nameof(HasCases));
[RelayCommand]
private async Task AddVorgang()
{
if (OnEditVorgang is null) return;
var result = await OnEditVorgang(_rosterStudentOptions, null);
if (result is null) return;
_vorgaenge.Save(result);
await LoadInternal();
}
[RelayCommand]
private async Task EditVorgang(VorgangItem? item)
{
if (item is null || OnEditVorgang is null) return;
var result = await OnEditVorgang(_rosterStudentOptions, item.Model);
if (result is null) return;
_vorgaenge.Save(result);
await LoadInternal();
}
[RelayCommand]
private async Task ToggleStatus(VorgangItem? item)
{
if (item is null) return;
item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open;
item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null;
_vorgaenge.Save(item.Model);
await LoadInternal();
}
[RelayCommand]
private async Task DeleteVorgang(VorgangItem? item)
{
if (item is null) return;
if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return;
_vorgaenge.Delete(item.Model.Id);
await LoadInternal();
}
[RelayCommand]
private async Task CreateAndLinkDocumentation(VorgangItem? item)
{
if (item is null || OnEditDocumentation is null) return;
var studentOptions = _rosterStudentOptions.Where(s => item.Model.StudentIds.Contains(s.Id)).ToList();
var defaultStudentId = item.Model.StudentIds.FirstOrDefault();
var result = await OnEditDocumentation(defaultStudentId, studentOptions, null);
if (result is null) return;
_documentation.Save(result);
item.Model.DocumentationIds.Add(result.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
/// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist —
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
/// <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();
}
private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Add(doc.Model.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
private async Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Remove(doc.Model.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
private async Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
{
item.Model.ClassRegisterEntries.Remove(row.Model);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
// ── Anheften eines Klassenbuch-Eintrags aus dem Klassenbuch-Tab (ClassTeacherDetailsViewModel,
// per Konstruktor-Injection dieser Instanz — kein Func-Hook, da hier keine UI im Spiel ist,
// nur Zugriff auf schon geladene Daten dieser Geschwister-ViewModel-Instanz) ────────────────
public IReadOnlyList<StudentOption> RosterStudentOptions => _rosterStudentOptions;
/// Offene Vorgänge, an die sich ein Klassenbuch-Eintrag für diese/n Schüler*in anheften lässt
/// (Namensabgleich wie beim restlichen Klassenlehrer-Bereich, siehe UntisNameMatching).
public List<VorgangItem> OpenCasesForStudent(string untisDisplayName)
{
var match = _rosterStudentOptions.FirstOrDefault(s =>
UntisNameMatching.NamesMatch(s.Name, untisDisplayName));
if (match is null) return [];
return Cases.Where(c => c.IsOpen && c.Model.StudentIds.Contains(match.Id)).ToList();
}
public async Task PinClassRegisterEntryAsync(Guid vorgangId, VorgangClassRegisterEntry entry)
{
var vorgang = _vorgaenge.GetById(vorgangId);
if (vorgang is null) return;
vorgang.ClassRegisterEntries.Add(entry);
_vorgaenge.Save(vorgang);
await LoadInternal();
}
public async Task<Vorgang> CreateAndPinAsync(string title, Guid studentId, VorgangClassRegisterEntry entry)
{
var vorgang = new Vorgang { Title = title, StudentIds = [studentId] };
vorgang.ClassRegisterEntries.Add(entry);
_vorgaenge.Save(vorgang);
await LoadInternal();
return vorgang;
}
}
@@ -135,6 +135,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
private readonly UntisReportCacheService _cache; private readonly UntisReportCacheService _cache;
private readonly IDocumentationRepository _documentation; private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly ClassTeacherCasesViewModel _cases;
private string _className = ""; private string _className = "";
private DateOnly _loadedStart; private DateOnly _loadedStart;
private DateOnly _loadedEnd; private DateOnly _loadedEnd;
@@ -149,6 +150,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = []; public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = []; public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-6); [ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und laden."; [ObservableProperty] private string _status = "Zeitraum wählen und laden.";
@@ -164,6 +168,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; } public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; }
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; } public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; }
/// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen
/// oder einen neuen Titel eingeben. Liefert null bei Abbruch.
public Func<ClassTeacherClassRegisterRow, Task<PinToVorgangChoice?>>? OnPickVorgangForPin { get; set; }
public bool HasEntries => Entries.Count > 0; public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
@@ -177,11 +184,12 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
? "Alle Schüler*innen" : StudentFilter; ? "Alle Schüler*innen" : StudentFilter;
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation, public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
IStudentRepository students) IStudentRepository students, ClassTeacherCasesViewModel cases)
{ {
_cache = cache; _cache = cache;
_documentation = documentation; _documentation = documentation;
_students = students; _students = students;
_cases = cases;
} }
public void Initialize(string className) public void Initialize(string className)
@@ -359,6 +367,42 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
LoadOwnDocumentationEntries(); LoadOwnDocumentationEntries();
} }
/// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese
/// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases).
public List<VorgangItem> OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName);
/// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang
/// im "Vorgänge"-Tab (<see cref="ClassTeacherCasesViewModel"/>) an — die Zeile selbst hat keine
/// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry).
[RelayCommand]
private async Task PinToVorgang(ClassTeacherClassRegisterRow? row)
{
if (row is null || OnPickVorgangForPin is null) return;
var choice = await OnPickVorgangForPin(row);
if (choice is null) return;
var entry = BuildClassRegisterSnapshot(row);
if (choice.ExistingVorgangId is { } vorgangId)
await _cases.PinClassRegisterEntryAsync(vorgangId, entry);
else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle))
{
var student = _cases.RosterStudentOptions
.FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName));
if (student is null) return;
await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry);
}
}
/// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff
/// testbare Umwandlung.
public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new()
{
Date = row.Date, StudentName = row.StudentName, Subject = row.Subject,
TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName,
CategoryGroup = row.CategoryGroup, Text = row.Text,
};
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem // WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein // StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen. // exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
@@ -322,6 +322,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private readonly AppLogger? _logger; private readonly AppLogger? _logger;
public ClassTeacherDetailsViewModel DetailsTab { get; } public ClassTeacherDetailsViewModel DetailsTab { get; }
public ClassTeacherCasesViewModel CasesTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
@@ -406,7 +407,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation, IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab, IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
AppLogger? logger = null) ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
{ {
_settings = settings; _settings = settings;
_untis = untis; _untis = untis;
@@ -419,6 +420,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
_participationSessions = participationSessions; _participationSessions = participationSessions;
_logger = logger; _logger = logger;
DetailsTab = detailsTab; DetailsTab = detailsTab;
CasesTab = casesTab;
} }
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
@@ -451,6 +453,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var className = HomeroomClassName!; var className = HomeroomClassName!;
DetailsTab.Initialize(className); DetailsTab.Initialize(className);
CasesTab.Initialize(className);
Busy = true; Busy = true;
NotifyRosterState(); NotifyRosterState();
try try
@@ -0,0 +1,204 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
// ── Anzeige-Helfer ────────────────────────────────────────────────────────
public static class VorgangTagDisplay
{
// Vom Nutzer selbst genannte Schlagwörter — eigene Labels bleiben trotzdem frei möglich
// (AutoCompleteBox, gleiches Muster wie DocumentationTagDisplay.Suggestions).
public static string[] Suggestions { get; } =
["Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte", "Mitarbeit", "Elternkontakt", "Eskalation"];
}
public static class VorgangStatusDisplay
{
public static string Label(VorgangStatus status) => status == VorgangStatus.Closed ? "Geschlossen" : "Offen";
}
/// Ergebnis des "→ Vorgang"-Auswahldialogs beim Anheften einer Klassenbuchzeile: entweder ein
/// bestehender, offener Vorgang oder der Titel für einen neu anzulegenden.
public sealed record PinToVorgangChoice(Guid? ExistingVorgangId, string? NewVorgangTitle);
/// Eingefrorene WebUntis-Klassenbuchzeile mit deutscher Anzeige-Aufbereitung — das Core-Modell
/// <see cref="VorgangClassRegisterEntry"/> bleibt bewusst framework-frei ohne Formatierungslogik.
public sealed record VorgangClassRegisterEntryRow(VorgangClassRegisterEntry Model)
{
public string DateLabel => Model.Date.ToString("dd.MM.yyyy");
public string StudentName => Model.StudentName;
public string SummaryLabel => string.Join(" · ", new[] { Model.Subject, Model.CategoryName, Model.Text }
.Where(v => !string.IsNullOrWhiteSpace(v)));
}
// ── Mehrfachauswahl Schüler*innen (Anlegen/Bearbeiten-Dialog) ────────────────
public partial class VorgangStudentOption(Guid studentId, string name) : ObservableObject
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
[ObservableProperty] private bool _isSelected;
}
// ── Listen-Eintrag mit aufklappbarer Detailansicht ───────────────────────────
/// <summary>
/// Ein Vorgang in der Liste des "Vorgänge"-Tabs. Verknüpfte Dokumentation/Klassenbucheinträge
/// werden von <see cref="ClassTeacherCasesViewModel"/> beim Laden befüllt; Link/Unlink-Aktionen
/// laufen über die hier übergebenen Callbacks zurück ins ViewModel (statt eigenem Repository-
/// Zugriff hier), damit dieser Wrapper ein reiner Anzeige-Baustein bleibt — analog
/// <see cref="DocumentationItem"/>.
/// </summary>
public partial class VorgangItem : ObservableObject
{
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
public Vorgang Model { get; }
public string StudentNames { get; }
public List<TagChip> TagChips { get; }
public string StatusLabel => VorgangStatusDisplay.Label(Model.Status);
public bool IsOpen => Model.Status == VorgangStatus.Open;
public string CreatedLabel => $"Angelegt {Model.CreatedAt:dd.MM.yyyy}";
public ObservableCollection<DocumentationItem> LinkedDocumentation { get; } = [];
public ObservableCollection<DocumentationItem> AvailableDocumentation { get; } = [];
public ObservableCollection<VorgangClassRegisterEntryRow> ClassRegisterRows { get; } = [];
public bool HasLinkedDocumentation => LinkedDocumentation.Count > 0;
public bool HasAvailableDocumentation => AvailableDocumentation.Count > 0;
public bool HasClassRegisterRows => ClassRegisterRows.Count > 0;
[ObservableProperty] private bool _isRevealed;
public VorgangItem(Vorgang model, string studentNames,
Func<VorgangItem, DocumentationItem, Task> onLink,
Func<VorgangItem, DocumentationItem, Task> onUnlink,
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry)
{
Model = model;
StudentNames = studentNames;
TagChips = model.Tags.Select(t => new TagChip(t)).ToList();
_onLink = onLink;
_onUnlink = onUnlink;
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
}
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
[RelayCommand]
private async Task Link(DocumentationItem? doc)
{
if (doc is not null) await _onLink(this, doc);
}
[RelayCommand]
private async Task Unlink(DocumentationItem? doc)
{
if (doc is not null) await _onUnlink(this, doc);
}
[RelayCommand]
private async Task RemoveClassRegisterEntry(VorgangClassRegisterEntryRow? row)
{
if (row is not null) await _onRemoveClassRegisterEntry(this, row);
}
}
// ── Dialog: Vorgang anlegen/bearbeiten ────────────────────────────────────
public partial class VorgangDialogViewModel : ObservableObject
{
private readonly Vorgang? _editing;
public ObservableCollection<VorgangStudentOption> StudentOptions { get; }
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _description = "";
[ObservableProperty] private string _newTag = "";
public ObservableCollection<string> Tags { get; } = [];
public string[] TagSuggestions => VorgangTagDisplay.Suggestions;
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _studentsError = "";
public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten";
public Vorgang? Result { get; private set; }
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
{
_editing = editing;
StudentOptions = new ObservableCollection<VorgangStudentOption>(rosterStudents.Select(s =>
new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true }));
if (editing is null) return;
Title = editing.Title;
Description = editing.Description;
foreach (var tag in editing.Tags) Tags.Add(tag);
}
[RelayCommand]
private void AddTag()
{
if (string.IsNullOrWhiteSpace(NewTag)) return;
var tag = NewTag.Trim();
if (!Tags.Contains(tag)) Tags.Add(tag);
NewTag = "";
}
[RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); }
[RelayCommand]
private void Save()
{
TitleError = ""; StudentsError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
var selectedIds = StudentOptions.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
if (selectedIds.Count == 0) { StudentsError = "Mindestens eine/n Schüler*in auswählen."; valid = false; }
if (!valid) return;
Result = _editing ?? new Vorgang();
Result.Title = Title.Trim();
Result.Description = (Description ?? "").Trim();
Result.StudentIds = selectedIds;
Result.Tags = Tags.ToList();
}
}
// ── Dialog: Klassenbuchzeile an (bestehenden oder neuen) Vorgang anheften ────
public partial class PinToVorgangDialogViewModel : ObservableObject
{
public string RowSummary { get; }
public List<VorgangItem> MatchingOpenCases { get; }
public bool HasMatchingOpenCases => MatchingOpenCases.Count > 0;
[ObservableProperty] private VorgangItem? _selectedCase;
[ObservableProperty] private string _newTitle = "";
[ObservableProperty] private string _error = "";
public PinToVorgangChoice? Result { get; private set; }
public PinToVorgangDialogViewModel(string rowSummary, List<VorgangItem> matchingOpenCases)
{
RowSummary = rowSummary;
MatchingOpenCases = matchingOpenCases;
}
[RelayCommand]
private void Confirm()
{
Error = "";
if (SelectedCase is not null) { Result = new PinToVorgangChoice(SelectedCase.Model.Id, null); return; }
if (!string.IsNullOrWhiteSpace(NewTitle)) { Result = new PinToVorgangChoice(null, NewTitle.Trim()); return; }
Error = "Bestehenden Vorgang wählen oder Titel für einen neuen Vorgang eingeben.";
}
}
@@ -0,0 +1,187 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherCasesView"
x:DataType="vm:ClassTeacherCasesViewModel">
<UserControl.Styles>
<Style Selector="Border.statusPill">
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="8,2"/>
</Style>
<Style Selector="Border.statusPill.open">
<Setter Property="Background" Value="#FB8C00"/>
</Style>
<Style Selector="Border.statusPill.closed">
<Setter Property="Background" Value="#43A047"/>
</Style>
<Style Selector="Border.statusPill TextBlock">
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Vorgänge" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Fallmappen für laufende Probleme mit einer/einem oder mehreren Schüler*innen der Klasse"
FontSize="12" Opacity="0.55"/>
</StackPanel>
<CheckBox Grid.Column="1" Content="Nur offene" IsChecked="{Binding OnlyOpen}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Grid.Column="2" Content=" Vorgang" Command="{Binding AddVorgangCommand}"/>
</Grid>
<TextBlock Grid.Row="1" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
<ScrollViewer Grid.Row="2">
<StackPanel>
<ItemsControl ItemsSource="{Binding Cases}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:VorgangItem">
<Border Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="12,10" Margin="0,0,0,8">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto">
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Classes="statusPill" Classes.open="{Binding IsOpen}" Classes.closed="{Binding !IsOpen}">
<TextBlock Text="{Binding StatusLabel}"/>
</Border>
<TextBlock Text="{Binding Model.Title}" FontWeight="SemiBold" FontSize="14"/>
</StackPanel>
<TextBlock Text="{Binding StudentNames}" FontSize="12" Opacity="0.7" Margin="0,2,0,0"/>
<ItemsControl ItemsSource="{Binding TagChips}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:TagChip">
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2" Margin="0,4,0,0">
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button Content="Details anzeigen" FontSize="11" Padding="8,3"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<Button Content="Details ausblenden" FontSize="11" Padding="8,3"
Command="{Binding RevealCommand}" IsVisible="{Binding IsRevealed}"/>
</StackPanel>
<Button Grid.Column="2" Content="Bearbeiten" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).EditVorgangCommand}"
CommandParameter="{Binding}"/>
<StackPanel Grid.Column="3" Orientation="Horizontal">
<Button Content="Schließen" FontSize="11" Padding="8,3" IsVisible="{Binding IsOpen}"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).ToggleStatusCommand}"
CommandParameter="{Binding}"/>
<Button Content="Wieder öffnen" FontSize="11" Padding="8,3" IsVisible="{Binding !IsOpen}"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).ToggleStatusCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
<Button Grid.Column="4" Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).DeleteVorgangCommand}"
CommandParameter="{Binding}"/>
</Grid>
<StackPanel Spacing="10" IsVisible="{Binding IsRevealed}" Margin="0,4,0,0">
<TextBlock Text="{Binding Model.Description}" FontSize="12" TextWrapping="Wrap" Opacity="0.85"
IsVisible="{Binding Model.Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="4">
<TextBlock Text="Verknüpfte Dokumentation" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding LinkedDocumentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Grid ColumnDefinitions="70,*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
</StackPanel>
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<Button Grid.Column="3" Content="Entfernen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).UnlinkCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Dokumentation verknüpft." FontSize="11" Opacity="0.5"
IsVisible="{Binding !HasLinkedDocumentation}"/>
<Button Content=" Neue Dokumentation anlegen" FontSize="11" Padding="8,3"
HorizontalAlignment="Left"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherCasesViewModel)DataContext).CreateAndLinkDocumentationCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding HasAvailableDocumentation}">
<TextBlock Text="Bestehende Dokumentation der Schüler*innen verknüpfen" FontSize="12"
FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding AvailableDocumentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Grid ColumnDefinitions="70,*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Model.Title}" FontSize="12" IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="12" Opacity="0.6" IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<TextBlock Text="{Binding TypeLabel}" FontSize="10" Opacity="0.5"/>
</StackPanel>
<Button Grid.Column="2" Content="Anzeigen" FontSize="10" Padding="6,2" Margin="0,0,4,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<Button Grid.Column="3" Content="Verknüpfen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).LinkCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Angeheftete Klassenbucheinträge" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding ClassRegisterRows}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:VorgangClassRegisterEntryRow">
<Grid ColumnDefinitions="70,*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" FontSize="11" Opacity="0.5"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.7" TextWrapping="Wrap"/>
</StackPanel>
<Button Grid.Column="2" Content="Entfernen" FontSize="10" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:VorgangItem)DataContext).RemoveClassRegisterEntryCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Klassenbucheinträge angeheftet — im Klassenbuch-Tab über „→ Vorgang&#8220; möglich."
FontSize="11" Opacity="0.5" IsVisible="{Binding !HasClassRegisterRows}" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Vorgänge." Classes="emptyhint" IsVisible="{Binding !HasCases}"/>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,63 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherCasesView : UserControl
{
public ClassTeacherCasesView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is not ClassTeacherCasesViewModel vm) return;
vm.OnEditVorgang = ShowVorgangDialog;
vm.OnConfirmDeleteVorgang = ShowDeleteVorgangDialog;
vm.OnEditDocumentation = ShowDocumentationDialog;
}
private async Task<Vorgang?> ShowVorgangDialog(List<StudentOption> rosterStudents, Vorgang? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new VorgangDialogViewModel(rosterStudents, editing);
var dialog = new VorgangDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
return saved ? vm.Result : null;
}
private async Task<bool> ShowDeleteVorgangDialog(VorgangItem item)
{
var info = new ConfirmDialogInfo
{
Title = "Vorgang löschen?",
Message = $"\"{item.Model.Title}\" wird als gelöscht markiert und nicht mehr angezeigt. " +
"Verknüpfte Dokumentation bleibt erhalten.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<Documentation?> ShowDocumentationDialog(
Guid defaultStudentId, List<StudentOption> studentOptions, Documentation? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new DocumentationDialogViewModel(defaultStudentId, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions);
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
return saved ? vm.Result : null;
}
}
@@ -507,6 +507,9 @@
<ContentPage Header="Fehlzeiten"> <ContentPage Header="Fehlzeiten">
<views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/> <views:ClassTeacherAbsencesView DataContext="{Binding DetailsTab}"/>
</ContentPage> </ContentPage>
<ContentPage Header="Vorgänge">
<views:ClassTeacherCasesView DataContext="{Binding CasesTab}"/>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -62,8 +62,8 @@
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
<TextBlock Text="Klassenbuch" FontSize="22" FontWeight="SemiBold"/> <TextBlock Text="Klassenbuch" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht" FontSize="12" Opacity="0.55" <TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht. Rechtsklick auf einen Eintrag heftet ihn an einen Vorgang an."
IsVisible="{Binding !ShowOwnDocumentation}"/> FontSize="12" Opacity="0.55" IsVisible="{Binding !ShowOwnDocumentation}"/>
<TextBlock Text="Eigene Dokumentation zu Schüler*innen dieser Klasse" FontSize="12" Opacity="0.55" <TextBlock Text="Eigene Dokumentation zu Schüler*innen dieser Klasse" FontSize="12" Opacity="0.55"
IsVisible="{Binding ShowOwnDocumentation}"/> IsVisible="{Binding ShowOwnDocumentation}"/>
</StackPanel> </StackPanel>
@@ -147,7 +147,8 @@
</Border> </Border>
<Grid Grid.Row="4" IsVisible="{Binding !ShowOwnDocumentation}"> <Grid Grid.Row="4" IsVisible="{Binding !ShowOwnDocumentation}">
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid ItemsSource="{Binding Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}"
AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46"> CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
<DataGrid.Columns> <DataGrid.Columns>
@@ -159,6 +160,12 @@
<DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/> <DataGridTextColumn Header="Gruppe" Binding="{Binding CategoryGroup}" Width="0.8*"/>
<DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/> <DataGridTextColumn Header="Eintrag" Binding="{Binding Text}" Width="2*"/>
</DataGrid.Columns> </DataGrid.Columns>
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="→ An Vorgang anheften" Command="{Binding PinToVorgangCommand}"
CommandParameter="{Binding SelectedEntry}"/>
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid> </DataGrid>
<StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5"> <StackPanel IsVisible="{Binding !HasEntries}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="5">
<TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/> <TextBlock Text="Keine Klassenbucheinträge im gewählten Zeitraum" FontWeight="SemiBold"/>
@@ -19,6 +19,21 @@ public partial class ClassTeacherRegisterView : UserControl
if (DataContext is not ClassTeacherDetailsViewModel vm) return; if (DataContext is not ClassTeacherDetailsViewModel vm) return;
vm.OnEditOwnDocumentation = ShowDocumentationDialog; vm.OnEditOwnDocumentation = ShowDocumentationDialog;
vm.OnConfirmDeleteOwnDocumentation = ShowDeleteDocumentationDialog; vm.OnConfirmDeleteOwnDocumentation = ShowDeleteDocumentationDialog;
vm.OnPickVorgangForPin = ShowPinToVorgangDialog;
}
private async Task<PinToVorgangChoice?> ShowPinToVorgangDialog(ClassTeacherClassRegisterRow row)
{
if (DataContext is not ClassTeacherDetailsViewModel vm) return null;
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var summary = $"{row.DateLabel} · {row.StudentDisplayName} · {row.CategoryName}";
var matchingCases = vm.OpenCasesFor(row.StudentName);
var dialogVm = new PinToVorgangDialogViewModel(summary, matchingCases);
var dialog = new PinToVorgangDialog { DataContext = dialogVm };
var confirmed = await dialog.ShowDialog<bool>(owner);
return confirmed ? dialogVm.Result : null;
} }
private async Task<Documentation?> ShowDocumentationDialog( private async Task<Documentation?> ShowDocumentationDialog(
@@ -0,0 +1,44 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.PinToVorgangDialog"
x:DataType="vm:PinToVorgangDialogViewModel"
Title="An Vorgang anheften"
Width="420" SizeToContent="Height" MinHeight="260"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="An Vorgang anheften" Classes="dialogtitle"/>
<TextBlock Text="{Binding RowSummary}" FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<StackPanel Spacing="4" IsVisible="{Binding HasMatchingOpenCases}">
<TextBlock Text="Bestehenden offenen Vorgang wählen" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding MatchingOpenCases}" SelectedItem="{Binding SelectedCase}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VorgangItem">
<TextBlock Text="{Binding Model.Title}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBlock Text="— oder —" FontSize="11" Opacity="0.5" HorizontalAlignment="Center"
IsVisible="{Binding HasMatchingOpenCases}"/>
<StackPanel Spacing="4">
<TextBlock Text="Neuen Vorgang anlegen mit Titel" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewTitle}" PlaceholderText="z.B. Schuleschwänzen"/>
</StackPanel>
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Anheften" HorizontalAlignment="Stretch" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class PinToVorgangDialog : Window
{
public PinToVorgangDialog() => InitializeComponent();
private void OnConfirm(object? sender, RoutedEventArgs e)
{
if (DataContext is PinToVorgangDialogViewModel vm)
{
vm.ConfirmCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,80 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.VorgangDialog"
x:DataType="vm:VorgangDialogViewModel"
Title="{Binding DialogTitle}"
Width="480" SizeToContent="Height" MinHeight="360"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Beschreibung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"
PlaceholderText="Worum geht es? Was ist bisher passiert?"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Schlagwörter" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource SystemControlBackgroundBaseMediumBrush}"
CornerRadius="10" Padding="8,3">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding}" FontSize="11"/>
<Button Content="×" FontSize="11" Padding="0" Background="Transparent"
Command="{Binding $parent[ItemsControl].((vm:VorgangDialogViewModel)DataContext).RemoveTagCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<AutoCompleteBox Grid.Column="0" Text="{Binding NewTag}"
ItemsSource="{Binding TagSuggestions}"
FilterMode="Contains" MinimumPrefixLength="0"
PlaceholderText="Schlagwort (z.B. Absentismus)"/>
<Button Grid.Column="2" Content="" Command="{Binding AddTagCommand}"/>
</Grid>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Betroffene Schüler*innen *" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding StudentOptions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VorgangStudentOption">
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsSelected}" Margin="0,2,14,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding StudentsError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StudentsError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class VorgangDialog : Window
{
public VorgangDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is VorgangDialogViewModel vm)
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
+1
View File
@@ -146,6 +146,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SupervisionDuty>(context => context.SupervisionDuties); Simple<SupervisionDuty>(context => context.SupervisionDuties);
Simple<SubstitutionEntry>(context => context.SubstitutionEntries); Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<CompetencyDomain>(context => context.CompetencyDomains); Simple<CompetencyDomain>(context => context.CompetencyDomains);
Simple<Vorgang>(context => context.Vorgaenge);
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert. // Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
+39
View File
@@ -1852,6 +1852,45 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Parameter zurückfällt (Rückwärtskompatibilität für bestehende Aufrufer/Tests). Parameter zurückfällt (Rückwärtskompatibilität für bestehende Aufrufer/Tests).
**Bewusst zurückgestellt:** einstellbare Gewichte (Schieberegler o.ä.) für `PatternScore` **Bewusst zurückgestellt:** einstellbare Gewichte (Schieberegler o.ä.) für `PatternScore`
Nutzer-Entscheidung für feste Werte zunächst, lässt sich bei Bedarf später ergänzen. Nutzer-Entscheidung für feste Werte zunächst, lässt sich bei Bedarf später ergänzen.
- [x] **"Klassenlehrer"-Feature — "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
(September 2026):** Nutzer-Feedback: manche Probleme (Schuleschwänzen, wiederholte Hausaufgaben-
Verweigerung, ein Konflikt) müssen intensiver abgearbeitet werden, dafür fehlte ein Ort, an dem
Titel, Problembeschreibung, Schlagwörter, alle dazugehörigen `Documentation`-Einträge und die
relevanten Klassenbucheinträge gebündelt einsehbar sind (auch für den Nachweis der Maßnahmen
gegenüber der Schulleitung) — statt über datumsgefilterte Listen verstreut.
Neues, eigenständiges Modell `Vorgang` (`LehrerApp.Core/Models/Vorgang.cs`): Titel, Beschreibung,
`StudentIds` (eine/r oder mehrere), freie `Tags` (`VorgangTagDisplay.Suggestions`: Absentismus,
Hausaufgaben, Verspätungen, Konflikte, Mitarbeit, Elternkontakt, Eskalation), Status Offen/
Geschlossen, `DocumentationIds` (**nur Referenzen, kein gecachter Inhalt trotz Nutzer-Idee einer
"Uploadversionsnummer"** — es gibt keine app-seitig nutzbare Versionsnummer pro Objekt, nur einen
internen Server-Sequenzzähler in `EventQueue`, und die Datenmenge pro Lehrkraft ist klein genug,
dass ein Cache nur Invalidierungs-Komplexität eingeführt hätte; gleicher Präzedenzfall wie die
bewusst verworfene Plan-ID-Gruppierung für Förderpläne, 5.3.3), sowie eingefrorene
`ClassRegisterEntries` (**Werte-Kopie statt Referenz** — `UntisForeignClassRegisterEventDto`/
`UntisClassRegisterCacheEntry` haben keine über einen Cache-Refresh hinweg stabile ID). Sync-fähig
nach demselben Muster wie `Documentation` (`VorgangRepository.Save`/`Delete` feuern `db.OnChange`,
`EventApplier` nutzt dafür den generischen `Simple<Vorgang>`-Helfer statt eines Kaskaden-
Sonderfalls, da kein Hard-Delete existiert).
Neuer vierter Tab "Vorgänge" in der Klassenlehreransicht (`ClassTeacherCasesViewModel`/
`ClassTeacherCasesView`, Geschwister-ViewModel zu `DetailsTab`, löst wie dieses die Untis-Roster-
Namen der Klasse auf lokale `Student`s auf — bewusst ein dritter, nicht geteilter Abgleich nach
demselben kleinen Muster, siehe `ClassTeacherOverviewViewModel.MatchStudent`). Jede Fallmappe ist
eine aufklappbare Karte (`VorgangItem`, gleiches Reveal-Muster wie `DocumentationItem`) mit
verknüpfter Dokumentation (neu anlegen-und-verknüpfen über den bestehenden `DocumentationDialog`,
oder bestehende Einträge der Vorgangs-Schüler*innen nachträglich verknüpfen/entfernen) und
angehefteten Klassenbucheinträgen. Anheften läuft vom Klassenbuch-Tab aus: Rechtsklick auf eine
Zeile → Kontextmenü "→ An Vorgang anheften" (bewusst kein `DataGridTemplateColumn`-Button, da ein
`$parent[DataGrid]`-Bindungspfad in Zellen-Templates in diesem Codebasis-Stand nirgends erprobt
ist — `DataGrid.SelectedItem` zweigleisig gebunden + `ContextMenu.CommandParameter` ist das
bereits an anderer Stelle (`GroupDetailView`) bewährte Muster für Zeilen-Aktionen), öffnet einen
Auswahldialog (bestehenden offenen, zur/zum Schüler*in passenden Vorgang wählen oder per Titel
einen neuen anlegen). `ClassTeacherDetailsViewModel` bekommt dafür `ClassTeacherCasesViewModel`
per Konstruktor-Injection (Geschwister-VM-Zugriff für reine Datenabfragen statt Func-Hook, da
keine UI im Spiel ist).
**Bewusst zurückgestellt ("Runde 2", Nutzer-Wunsch):** dass ein bearbeiteter/geschlossener Vorgang
automatisch den `PatternScore` dämpft, sowie Trend-Indikatoren ("bewegt sich wieder in die
falsche Richtung", z.B. Fehlzeiten oder Konflikt-Einträge nehmen erneut zu) — reines Anlegen/
Verknüpfen/Verwalten in dieser Runde, keine Rückwirkung auf Score oder Sortierung.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3