Files
adminandClaude Opus 5.5 9a84bbaa38
CI / build-and-test (push) Canceled after 0s
Gemeinsame Dokumentation für mehrere Beteiligte eines Vorgangs
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>
2026-09-28 08:43:24 +02:00

284 lines
13 KiB
C#

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.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>
/// Schülerbezogener "Vorgänge"-Tab im Schülerdetail — schmalere Variante von
/// <see cref="ClassTeacherCasesViewModel"/> ohne Klassen-Roster/WebUntis-Klassenbuch-Kopplung: zeigt
/// und verwaltet nur die Vorgänge dieser einen Person (<see cref="IVorgangRepository.GetByStudent"/>).
/// Universeller Einstiegspunkt für die klassenübergreifenden Dashboard-Warnungen (Fehlzeiten,
/// Hausaufgaben-Rückstand, Verspätungen) — der Klassenlehrer-"Vorgänge"-Tab bleibt bewusst
/// homeroom-only (siehe TODO.md). Beide teilen sich nur <see cref="Vorgang"/>/<see cref="VorgangItem"/>/
/// <see cref="VorgangDialogViewModel"/>, keine ViewModel-Logik.
/// </summary>
public partial class StudentVorgaengeViewModel : ObservableObject
{
private readonly IVorgangRepository _vorgaenge;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly StudentWarningEvidenceService? _evidence;
private Guid _studentId;
// Id eines aus einer Vorlage erzeugten, noch ungespeicherten Dokumentations-Entwurfs — damit
// der Dialog "hinzufügen" statt "bearbeiten" titelt (siehe IsNewDocumentationDraft).
private Guid? _documentationDraftId;
private string _studentName = "";
private List<StudentOption> _studentOptions = [];
public ObservableCollection<VorgangItem> Cases { get; } = [];
[ObservableProperty] private bool _onlyOpen = true;
public bool HasCases => Cases.Count > 0;
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { 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; }
/// <param name="evidence">Quelle für Schnellbausteine/Vorlagen mit dem aktuellen Stand
/// (Fehlquote, Hausaufgaben, Verspätungen). Optional, damit Tests ohne Mitarbeits-Repositories
/// auskommen — ohne sie entfallen Bausteine und Vorlagen einfach.</param>
public StudentVorgaengeViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
IStudentRepository students, StudentWarningEvidenceService? evidence = null)
{
_vorgaenge = vorgaenge;
_documentation = documentation;
_students = students;
_evidence = evidence;
}
/// Frisch berechnet bei jedem Dialog-Aufruf statt beim Laden gecacht — der Tab kann lange offen
/// bleiben, während im Unterricht weiter Anwesenheit erfasst wird.
public IReadOnlyList<VorgangQuickBlock> BuildQuickBlocks() =>
_evidence is null ? [] : StudentEvidenceText.QuickBlocks(_evidence.Build(_studentId));
/// Ob ein an OnEditVorgang übergebener Vorgang schon gespeichert ist oder nur ein vorbefüllter
/// Entwurf (HandleWarningNavigation) — steuert den Dialogtitel "anlegen"/"bearbeiten".
public bool IsPersisted(Vorgang vorgang) => _vorgaenge.GetById(vorgang.Id) is not null;
public bool IsNewDocumentationDraft(Documentation doc) => doc.Id == _documentationDraftId;
partial void OnOnlyOpenChanged(bool value) => Load(_studentId);
[RelayCommand] private void Refresh() => Load(_studentId);
/// Bewusst synchron (anders als ClassTeacherCasesViewModel.LoadInternal) — kein WebUntis-Roster
/// aufzulösen, deshalb kann HandleWarningNavigation direkt im Anschluss laufen, ohne auf einen
/// asynchronen Ladevorgang warten zu müssen.
public void Load(Guid studentId)
{
_studentId = studentId;
_studentName = _students.GetById(studentId)?.FullName ?? "";
_studentOptions = [new StudentOption(studentId, _studentName)];
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt (gleiches Muster wie
// ClassTeacherCasesViewModel.LoadInternal).
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
Cases.Clear();
IEnumerable<Vorgang> vorgaenge = _vorgaenge.GetByStudent(studentId);
if (OnlyOpen) vorgaenge = vorgaenge.Where(v => v.Status == VorgangStatus.Open);
// 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))
{
var names = string.Join(", ", vorgang.StudentIds
.Select(id => id == studentId ? _studentName : _students.GetById(id)?.FullName)
.Where(n => !string.IsNullOrEmpty(n)));
var item = new VorgangItem(vorgang, names, LinkDocumentation, UnlinkDocumentation,
RemoveClassRegisterEntry, EditDocumentation)
{
IsRevealed = revealedIds.Contains(vorgang.Id),
OnCreateFromTemplate = _evidence is null ? null : CreateDocumentationFromTemplate,
};
var linkedIds = vorgang.DocumentationIds.ToHashSet();
foreach (var doc in ownDocs.Where(d => linkedIds.Contains(d.Id)))
item.LinkedDocumentation.Add(new DocumentationItem(doc, InvolvedNames(doc)));
foreach (var doc in ClassTeacherCasesViewModel.FilterAvailableDocumentation(ownDocs, vorgang))
item.AvailableDocumentation.Add(new DocumentationItem(doc, InvolvedNames(doc)));
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
Cases.Add(item);
}
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()
{
if (OnEditVorgang is null) return;
var result = await OnEditVorgang(_studentOptions, null);
if (result is null) return;
_vorgaenge.Save(result);
Load(_studentId);
}
[RelayCommand]
private async Task EditVorgang(VorgangItem? item)
{
if (item is null || OnEditVorgang is null) return;
var result = await OnEditVorgang(_studentOptions, item.Model);
if (result is null) return;
_vorgaenge.Save(result);
Load(_studentId);
}
[RelayCommand]
private void 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);
Load(_studentId);
}
[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);
Load(_studentId);
}
[RelayCommand]
private async Task CreateAndLinkDocumentation(VorgangItem? item)
{
if (item is null || OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_studentId, VorgangStudentOptions(item.Model), null);
if (result is null) return;
_documentation.Save(result);
item.Model.DocumentationIds.Add(result.Id);
_vorgaenge.Save(item.Model);
Load(_studentId);
}
/// Erzeugt aus einer Vorlage (VorgangDocumentationTemplates) einen vorbefüllten Entwurf mit dem
/// aktuellen Stand, öffnet ihn im normalen Dokumentations-Dialog und verknüpft ihn nach dem
/// Speichern mit dem Vorgang — gleicher Abschluss wie CreateAndLinkDocumentation.
private async Task CreateDocumentationFromTemplate(VorgangItem item, string key)
{
if (_evidence is null || OnEditDocumentation is null) return;
var draft = VorgangDocumentationTemplates.Build(key, item.Model, _evidence.Build(_studentId));
if (draft is null) return;
_documentationDraftId = draft.Id;
Documentation? result;
try { result = await OnEditDocumentation(_studentId, VorgangStudentOptions(item.Model), draft); }
finally { _documentationDraftId = null; }
if (result is null) return;
_documentation.Save(result);
item.Model.DocumentationIds.Add(result.Id);
_vorgaenge.Save(item.Model);
Load(_studentId);
var reloaded = Cases.FirstOrDefault(c => c.Model.Id == item.Model.Id);
if (reloaded is not null) reloaded.IsRevealed = true;
}
private async Task EditDocumentation(DocumentationItem doc)
{
if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(doc.Model.StudentId, CoStudentOptionsFor(doc.Model), doc.Model);
if (result is null) return;
_documentation.Save(result);
Load(_studentId);
}
private Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Add(doc.Model.Id);
_vorgaenge.Save(item.Model);
Load(_studentId);
return Task.CompletedTask;
}
private Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Remove(doc.Model.Id);
_vorgaenge.Save(item.Model);
Load(_studentId);
return Task.CompletedTask;
}
private Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
{
item.Model.ClassRegisterEntries.Remove(row.Model);
_vorgaenge.Save(item.Model);
Load(_studentId);
return Task.CompletedTask;
}
/// <summary>
/// Von <see cref="MainWindowViewModel.NavigateToStudentCase"/> direkt nach dem Laden aufgerufen:
/// sucht einen offenen Vorgang mit passendem Tag (z.B. "Absentismus" für die Fehlzeiten-Warnung)
/// und klappt ihn auf. Findet sich keiner (auch ein geschlossener Vorgang mit passendem Tag zählt
/// hier bewusst nicht als Treffer — er taucht bei OnlyOpen=true gar nicht erst in <see cref="Cases"/>
/// auf), wird per Rückfrage ein neuer mit vorbelegtem Titel/Tag/Schüler angeboten.
/// </summary>
public async Task HandleWarningNavigation(string suggestedTag, string suggestedTitle)
{
var match = Cases.FirstOrDefault(c =>
c.Model.Tags.Contains(suggestedTag, StringComparer.OrdinalIgnoreCase));
if (match is not null)
{
match.IsRevealed = true;
return;
}
if (OnConfirmCreateForWarning is null || OnEditVorgang is null) return;
if (!await OnConfirmCreateForWarning(suggestedTag)) return;
// Nutzer-Feedback: die Zahlen, die die Warnung ausgelöst haben, gleich als Beschreibung
// mitgeben, statt sie im Dialog von Hand abzutippen — bleibt im Dialog frei editierbar.
var description = _evidence is null ? ""
: StudentEvidenceText.StatusBlockForTag(suggestedTag, _evidence.Build(_studentId)) ?? "";
var draft = new Vorgang
{
Title = suggestedTitle, Description = description, StudentIds = [_studentId], Tags = [suggestedTag],
};
var result = await OnEditVorgang(_studentOptions, draft);
if (result is null) return;
_vorgaenge.Save(result);
Load(_studentId);
var created = Cases.FirstOrDefault(c => c.Model.Id == result.Id);
if (created is not null) created.IsRevealed = true;
}
}