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>
236 lines
10 KiB
C#
236 lines
10 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.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;
|
|
}
|
|
}
|