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>
284 lines
13 KiB
C#
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;
|
|
}
|
|
}
|