CI / build-and-test (push) Canceled after 0s
Dashboard warnings (attendance, homework, lateness) get a "Details" flyout with key figures, a list of dates and the compact month calendar from the class teacher overview, now extracted as a shared control. The calendar now aligns days under their weekday (Mo-So header, blank leading cells). Creating Vorgänge and documentation is supported by quick blocks with the current figures, dated standard phrases (e.g. "Klassenleitung informiert") and documentation templates (status snapshot, conversation, informing the class/subject teachers, parent call, parent letter). The class teacher Vorgänge tab uses WebUntis figures (absences across all subjects, class register) for the same blocks and templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
12 KiB
C#
262 lines
12 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; }
|
|
public Func<Guid, 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 dieses einen Schülers (nicht GetAll()) — dieses Panel ist die Sicht
|
|
// "von diesem Schüler aus", nicht Vorgangsverwaltung für mehrere Beteiligte.
|
|
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, _studentName));
|
|
foreach (var doc in ClassTeacherCasesViewModel.FilterAvailableDocumentation(ownDocs, vorgang))
|
|
item.AvailableDocumentation.Add(new DocumentationItem(doc, _studentName));
|
|
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
|
|
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
|
|
|
|
Cases.Add(item);
|
|
}
|
|
OnPropertyChanged(nameof(HasCases));
|
|
}
|
|
|
|
[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, 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, 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, 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;
|
|
}
|
|
}
|