318 lines
15 KiB
C#
318 lines
15 KiB
C#
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using LehrerApp.Core.Models;
|
|
using LehrerApp.Desktop.Services;
|
|
|
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
|
|
|
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
|
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
|
{
|
|
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);
|
|
|
|
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
|
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
|
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
|
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
|
DateOnly? today = null)
|
|
{
|
|
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 recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
return students.Select(student =>
|
|
{
|
|
var nameKey = UntisNameMatching.NameKey(student.DisplayName);
|
|
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
|
?? absenceByName.GetValueOrDefault(nameKey);
|
|
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}",
|
|
registerEntries.Count > 0)
|
|
{
|
|
TodayAbsence = absence,
|
|
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
|
|
};
|
|
})
|
|
.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);
|
|
}
|
|
|
|
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;
|
|
private readonly UntisReportCacheService _cache;
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
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(); 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, start, today);
|
|
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, start, today);
|
|
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
|
|
|
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, 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; 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]
|
|
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
|
|
{
|
|
if (row is null) return;
|
|
DetailsTab.StudentFilter = row.StudentName;
|
|
ActiveTabIndex = row.HasAbsenceToday ? 2 : 1;
|
|
DetailsTab.LoadCommand.Execute(null);
|
|
}
|
|
|
|
[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()
|
|
{
|
|
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));
|
|
}
|
|
}
|