Redesign Klassenlehrer Tab.
This commit is contained in:
@@ -6,62 +6,91 @@ using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
|
||||
/// <summary>Eine Zeile der Klassenlehrer-Übersicht ("auf einen Blick", Nutzer-Feedback) - pro
|
||||
/// Schüler*in ein Ampel-Symbol für "heute laut WebUntis keine gemeldete Fehlzeit" und ein Badge,
|
||||
/// falls in den letzten Tagen ein Klassenbucheintrag einer anderen Lehrkraft angelegt wurde.
|
||||
/// Bewusst kein Bezug zu eigenem Unterricht/eigener Anwesenheitserfassung - genau der Fall, den es
|
||||
/// abzudecken gilt, ist der Tag ohne eigenen Unterricht mit der Klasse.</summary>
|
||||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||
{
|
||||
public string StatusIcon => HasAbsenceToday ? "⚠" : "✓";
|
||||
public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; }
|
||||
public bool HasClassRegisterToday { get; init; }
|
||||
public bool IsLate => TodayAbsence is { } a &&
|
||||
(a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason));
|
||||
public bool IsUnexcused => TodayAbsence?.Statuses.Any(s =>
|
||||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true;
|
||||
public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry;
|
||||
public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 :
|
||||
HasRecentClassRegisterEntry ? 3 : 4;
|
||||
|
||||
public string Initials
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TodayAbsence is null)
|
||||
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
|
||||
var core = IsLate
|
||||
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
|
||||
: TodayAbsence.CountsAsFullDay
|
||||
? "Ganzer Fehltag"
|
||||
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
|
||||
return IsUnexcused ? $"{core} · Unentschuldigt" : core;
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusColor => IsUnexcused ? "#FF5A67" :
|
||||
HasAbsenceToday ? "#F59E0B" : HasRecentClassRegisterEntry ? "#4C8DFF" : "#73C45A";
|
||||
public string StatusBarColor => StatusColor;
|
||||
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
|
||||
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Führt die WebUntis-Schülerliste der Klasse mit den heutigen Fehlzeiten und den
|
||||
/// jüngsten Klassenbucheinträgen zusammen. Zuordnung primär über <c>ExternKey</c> (beide
|
||||
/// stammen aus WebUntis, gleiche Kennung), Fallback auf reihenfolge-unabhängigen Namensabgleich
|
||||
/// (<see cref="UntisNameMatching"/>) für den Klassenbuch-Bericht, der keinen ExternKey liefert
|
||||
/// (siehe UntisForeignClassRegisterEventDto) — WebUntis liefert Namen dort in anderer
|
||||
/// Reihenfolge als im Schülerreport.</summary>
|
||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries)
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||
DateOnly? today = null)
|
||||
{
|
||||
var absenceByKey = todayAbsences
|
||||
.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value)
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||||
var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
var absenceByName = todayAbsences
|
||||
.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
var recentNames = recentClassRegisterEntries
|
||||
.Select(e => UntisNameMatching.NameKey(e.StudentName))
|
||||
.ToHashSet();
|
||||
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
return students
|
||||
.Select(s =>
|
||||
return students.Select(student =>
|
||||
{
|
||||
var nameKey = UntisNameMatching.NameKey(s.DisplayName);
|
||||
var absence = (s.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
||||
var nameKey = UntisNameMatching.NameKey(student.DisplayName);
|
||||
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
||||
?? absenceByName.GetValueOrDefault(nameKey);
|
||||
return new ClassTeacherRosterRow(s.DisplayName, s.ExternKey, absence is not null,
|
||||
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
|
||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||
recentNames.Contains(nameKey));
|
||||
registerEntries.Count > 0)
|
||||
{
|
||||
TodayAbsence = absence,
|
||||
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
||||
};
|
||||
})
|
||||
.OrderBy(r => r.StudentName)
|
||||
.ToList();
|
||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||
}
|
||||
|
||||
private static bool TryDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top-Level-Bereich "Klassenlehrer" (eigener Sidebar-Eintrag, siehe MainWindowViewModel) - bewusst
|
||||
/// nicht an eine LearningGroup gehängt, da man Klassenlehrer für eine ganze Klasse ist, unabhängig
|
||||
/// vom eigenen Unterricht (Nutzer-Feedback zur ursprünglichen Umsetzung als Gruppen-Tab). Die
|
||||
/// Klasse selbst kommt aus den WebUntis-Einstellungen (<see cref="WebUntisSettingsService.HomeroomClassName"/>).
|
||||
/// Zwei Ebenen: Übersicht (dieses ViewModel, kompaktes Roster) und Details
|
||||
/// (<see cref="ClassTeacherDetailsViewModel"/>, die bisherigen Rohdaten-Listen) - ein Klick auf
|
||||
/// eine Roster-Zeile springt gefiltert in die Details.
|
||||
/// </summary>
|
||||
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, int LateCount,
|
||||
double AlertBarWidth, double UnexcusedBarWidth, double LateBarWidth);
|
||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message, string Color);
|
||||
|
||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
@@ -69,57 +98,132 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||
|
||||
[ObservableProperty] private string? _homeroomClassName;
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
[ObservableProperty] private string _status = "";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private int _selectedRosterFilter;
|
||||
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
|
||||
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
|
||||
[ObservableProperty] private int _studentCount;
|
||||
[ObservableProperty] private int _todayAlertCount;
|
||||
[ObservableProperty] private int _todayUnexcusedCount;
|
||||
[ObservableProperty] private int _recentClassRegisterCount;
|
||||
[ObservableProperty] private int _presentCount;
|
||||
[ObservableProperty] private int _lateCount;
|
||||
[ObservableProperty] private int _excusedAbsenceCount;
|
||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||
|
||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
|
||||
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
|
||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
|
||||
public int LatePercent => Percent(LateCount);
|
||||
public int PresentPercent => Percent(PresentCount);
|
||||
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
|
||||
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
|
||||
|
||||
/// Vom Code-Behind/MainWindowViewModel gesetzt: springt in die WebUntis-Einstellungen, wenn noch
|
||||
/// keine Klasse gewählt ist.
|
||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
|
||||
UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab)
|
||||
{
|
||||
_settings = settings; _cache = cache; DetailsTab = detailsTab;
|
||||
_settings = settings;
|
||||
_cache = cache;
|
||||
DetailsTab = detailsTab;
|
||||
}
|
||||
|
||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||
partial void OnSelectedRosterFilterChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||||
OnPropertyChanged(nameof(AllFilterSelected));
|
||||
ApplyRosterFilter();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Load()
|
||||
{
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear();
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
NotifyRosterState();
|
||||
return;
|
||||
}
|
||||
|
||||
var className = HomeroomClassName!;
|
||||
DetailsTab.Initialize(className);
|
||||
Busy = true;
|
||||
NotifyRosterState();
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var start = today.AddDays(-6);
|
||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, today, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, today.AddDays(-7), today);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, start, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, start, today);
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
||||
|
||||
var todayAbsences = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var absenceDays = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||||
var todayAbsences = absenceDays.Where(a => a.Date == today).ToList();
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result))
|
||||
Roster.Add(row);
|
||||
Status = $"{Roster.Count} Schüler*innen · Stand heute.";
|
||||
classRegisterTask.Result, today)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||
LateCount = Roster.Count(r => r.IsLate);
|
||||
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
|
||||
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
BuildTrend(absenceDays, start, today);
|
||||
BuildPatternNotices(absenceDays);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
NotifySummary();
|
||||
}
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||
finally { Busy = false; }
|
||||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||||
finally { Busy = false; NotifyRosterState(); }
|
||||
}
|
||||
|
||||
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
|
||||
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
|
||||
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenClassRegister()
|
||||
{
|
||||
DetailsTab.StudentFilter = "";
|
||||
ActiveTabIndex = 1;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenAbsences()
|
||||
{
|
||||
DetailsTab.StudentFilter = "";
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -127,13 +231,87 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
if (row is null) return;
|
||||
DetailsTab.StudentFilter = row.StudentName;
|
||||
ActiveTabIndex = 1;
|
||||
ActiveTabIndex = row.HasAbsenceToday ? 2 : 1;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GoToSettings()
|
||||
[RelayCommand] private async Task GoToSettings()
|
||||
{ if (OnNavigateToSettings is not null) await OnNavigateToSettings(); }
|
||||
|
||||
[RelayCommand] private async Task GoToWorkload()
|
||||
{ if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); }
|
||||
|
||||
private void ApplyRosterFilter()
|
||||
{
|
||||
if (OnNavigateToSettings is not null) await OnNavigateToSettings();
|
||||
PrimaryRoster.Clear(); SecondaryRoster.Clear();
|
||||
var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) ||
|
||||
r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase));
|
||||
if (SelectedRosterFilter == 0)
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else if (SelectedRosterFilter == 1)
|
||||
{
|
||||
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
|
||||
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
PrimarySectionTitle = "Heute auffällig";
|
||||
SecondarySectionTitle = "Weitere Schüler*innen";
|
||||
foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row);
|
||||
foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row);
|
||||
}
|
||||
NotifyRosterState();
|
||||
}
|
||||
|
||||
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly start, DateOnly end)
|
||||
{
|
||||
var counts = Enumerable.Range(0, 7).Select(offset => start.AddDays(offset)).Select(date =>
|
||||
{
|
||||
var rows = absenceDays.Where(r => r.Date == date).ToList();
|
||||
return (Date: date, Alerts: rows.Count,
|
||||
Unexcused: rows.Count(r => r.Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase))),
|
||||
Late: rows.Count(r => r.TotalAbsentPeriods == 0 || r.AbsenceReasons.Any(a =>
|
||||
a.Contains("verspät", StringComparison.OrdinalIgnoreCase))));
|
||||
}).ToList();
|
||||
var max = Math.Max(1, counts.Max(c => c.Alerts));
|
||||
foreach (var day in counts)
|
||||
TrendDays.Add(new ClassTeacherTrendDay(day.Date == end ? "Heute" : day.Date.ToString("ddd"),
|
||||
day.Alerts, day.Unexcused, day.Late,
|
||||
112d * day.Alerts / max, 112d * day.Unexcused / max, 112d * day.Late / max));
|
||||
}
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
{
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
var displayName = Roster.FirstOrDefault(row =>
|
||||
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
|
||||
var lateDays = rows.Count(row => row.IsLate);
|
||||
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
if (unexcusedDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", "#FF5A67"));
|
||||
else if (lateDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDays}-mal verspätet in 7 Tagen", "#F59E0B"));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
|
||||
private void NotifySummary()
|
||||
{
|
||||
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
|
||||
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
|
||||
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
|
||||
}
|
||||
private void NotifyRosterState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
|
||||
OnPropertyChanged(nameof(HasNoFilterResults));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user