using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.
public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName => DisplayName(StudentName);
public ClassTeacherStatusKind CategoryKind =>
CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
private static string DisplayName(string value)
{
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
}
/// Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen
/// berechnet, kein zusätzlicher WebUntis-Abruf.
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
{
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
public static IReadOnlyList Build(
IReadOnlyList entries, int topStudentsPerCategory = 2) =>
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
.GroupBy(e => e.CategoryName!)
.Select(g =>
{
var topStudents = g.GroupBy(e => e.StudentDisplayName)
.Select(sg => (Name: sg.Key, Count: sg.Count()))
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
.Take(topStudentsPerCategory)
.Select(sg => $"{sg.Count}× {sg.Name}");
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
})
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
.ToList();
}
/// Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
/// Rohdaten). Die Rohzeilen () bleiben trotzdem erreichbar,
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList Subjects,
IReadOnlyList PeriodNumbers, IReadOnlyList Statuses, IReadOnlyList AbsenceReasons,
string? Note, string? ExcuseNote, bool CountsAsFullDay)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName
{
get
{
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? StudentName : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
}
public string SubjectsLabel => string.Join(", ", Subjects);
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
public string StatusLabel => string.Join(", ", Statuses);
public string ReasonLabel => string.Join("; ", AbsenceReasons);
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
public bool IsUnexcused => Statuses.Any(s =>
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
.Where(value => !string.IsNullOrWhiteSpace(value)));
public static IReadOnlyList GroupByStudentAndDay(
IEnumerable entries) => entries
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
.Select(g => new ClassAbsenceDaySummaryRow(
g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
Distinct(g.Select(x => x.Entry.Subject)),
g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
Distinct(g.Select(x => x.Entry.Status)),
Distinct(g.Select(x => x.Entry.AbsenceReason)),
g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Any(x => x.Entry.CountsAsFullDay)))
.OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
.ToList();
private static List Distinct(IEnumerable values) =>
values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
///
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der
/// Klasse über alle Fächer — beide über (lokal gecacht statt
/// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in
/// lokale Daten. Bewusst unabhängig von jeder —
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
/// kommt aus (letztlich aus den WebUntis-Einstellungen),
/// nicht aus einer Gruppen-ID.
///
public partial class ClassTeacherDetailsViewModel : ObservableObject
{
private readonly UntisReportCacheService _cache;
private string _className = "";
public ObservableCollection Entries { get; } = [];
public ObservableCollection AbsenceEntries { get; } = [];
public ObservableCollection CategoryAggregates { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
[ObservableProperty] private bool _busy;
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = "";
[ObservableProperty] private int _quickRangeIndex = 1;
public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter;
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
{
_cache = cache;
}
public void Initialize(string className)
{
_className = className;
StudentFilter = "";
Entries.Clear();
AbsenceEntries.Clear();
CategoryAggregates.Clear();
Status = "Zeitraum wählen und laden.";
NotifyListState();
}
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
partial void OnQuickRangeIndexChanged(int value)
{
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
EndDate = DateTimeOffset.Now;
StartDate = DateTimeOffset.Now.AddDays(-days);
}
[RelayCommand]
private Task Load() => LoadInternal(forceRefresh: false);
/// Umgeht bewusst die Stunden-Sperre von — für den Fall,
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
[RelayCommand]
private Task Refresh() => LoadInternal(forceRefresh: true);
[RelayCommand]
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
[RelayCommand]
private async Task ClearStudentFilter()
{
StudentFilter = "";
await LoadInternal(forceRefresh: false);
}
private async Task LoadInternal(bool forceRefresh)
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
try
{
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
await Task.WhenAll(classRegisterTask, absencesTask);
var ordered = classRegisterTask.Result
.Where(MatchesStudentFilter)
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
foreach (var (entry, date) in ordered)
{
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
}
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
var absences = absencesTask.Result.Where(MatchesStudentFilter);
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row);
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
NotifyListState();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; NotifyListState(); }
}
// 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
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private void NotifyListState()
{
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(HasCategoryAggregates));
}
}