Zwei Bugs behoben: - Gefilterte Listen blieben nach Klick auf eine Roster-Zeile leer: WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge (Schülerreport "Vorname Nachname", Fehlzeiten-/Klassenbuchberichte "Nachname Vorname"). Neuer reihenfolge-unabhängiger Namensvergleich (UntisNameMatching), betraf auch den Roster-Namensabgleich der Übersicht selbst (Ampel-Symbole/Badges waren ohne ExternKey vermutlich fast immer falsch). - Nach dem ersten Tab-Wechsel (Klick auf Roster-Zeile) blieb ein zweiter Klick wirkungslos, sobald man zwischendurch manuell zur Übersicht zurückgeklickt hatte: TabbedPage.SelectedIndex war ohne Mode=TwoWay gebunden. Explizit ergänzt. Zusätzlich: Fehlzeiten-Zeilen für einen kompletten Fehltag werden jetzt dezent hervorgehoben (Nutzer-Feedback, bisher nicht erkennbar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
157 lines
8.5 KiB
C#
157 lines
8.5 KiB
C#
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using LehrerApp.Desktop.Services;
|
|
|
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
|
|
|
/// <summary>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.</summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>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 (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
|
|
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
|
|
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
|
|
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
|
|
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
|
|
string? Note, string? ExcuseNote, bool CountsAsFullDay)
|
|
{
|
|
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
|
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);
|
|
/// Nutzer-Feedback: ein ganzer Fehltag ist in der Liste sonst nicht auf einen Blick erkennbar -
|
|
/// dezente Hervorhebung statt einer weiteren Text-/Icon-Spalte.
|
|
public string RowBackground => CountsAsFullDay ? "#1FFFA000" : "Transparent";
|
|
public string? FullDayTooltip => CountsAsFullDay ? "Ganzer Fehltag" : null;
|
|
|
|
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
|
|
IEnumerable<UntisClassAbsenceEntryDto> 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<string> Distinct(IEnumerable<string?> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="UntisReportCacheService"/> (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 <see cref="Core.Models.LearningGroup"/> —
|
|
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
|
|
/// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
|
|
/// nicht aus einer Gruppen-ID.
|
|
/// </summary>
|
|
public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|
{
|
|
private readonly UntisReportCacheService _cache;
|
|
private string _className = "";
|
|
|
|
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
|
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
|
|
|
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
|
|
[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 = "";
|
|
|
|
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
|
|
{
|
|
_cache = cache;
|
|
}
|
|
|
|
public void Initialize(string className)
|
|
{
|
|
_className = className;
|
|
Entries.Clear();
|
|
AbsenceEntries.Clear();
|
|
Status = "Zeitraum wählen und laden.";
|
|
}
|
|
|
|
[RelayCommand]
|
|
private Task Load() => LoadInternal(forceRefresh: false);
|
|
|
|
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — 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);
|
|
|
|
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();
|
|
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));
|
|
}
|
|
|
|
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.";
|
|
}
|
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
|
finally { Busy = false; }
|
|
}
|
|
|
|
// 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);
|
|
}
|