From 87a7badb441ace418d31558de2f717c0d1efb78d Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 25 Aug 2026 00:21:53 +0200 Subject: [PATCH] Untis-API: Fehlzeiten abgleich --- LehrerApp.Core/Models/LearningGroup.cs | 6 + .../Services/GroupRolloverService.cs | 2 + .../Services/WebUntisIntegrationService.cs | 19 +- .../ViewModels/Groups/GroupViewModels.cs | 3 + .../WebUntisAbsenceComparisonViewModel.cs | 128 ----------- ...ebUntisLessonAbsenceComparisonViewModel.cs | 202 ++++++++++++++++++ .../Views/Groups/AddGroupDialog.axaml | 7 + .../Views/Groups/GroupDetailView.axaml | 2 +- .../Views/Groups/GroupDetailView.axaml.cs | 6 +- .../WebUntisAbsenceComparisonDialog.axaml | 42 ---- ...ebUntisLessonAbsenceComparisonDialog.axaml | 53 +++++ ...tisLessonAbsenceComparisonDialog.axaml.cs} | 4 +- LehrerApp.Tests/GroupRolloverServiceTests.cs | 3 +- .../WebUntisClientTests.cs | 53 +++-- .../WebUntisLessonAbsenceParserTests.cs | 54 +++++ LehrerApp.WebUntis/TabSeparatedTextReader.cs | 55 +++++ LehrerApp.WebUntis/WebUntisClient.cs | 60 +++--- .../WebUntisLessonAbsenceParser.cs | 94 ++++++++ LehrerApp.WebUntis/WebUntisModels.cs | 24 +-- .../WebUntisStudentReportParser.cs | 52 +---- TODO.md | 22 ++ 21 files changed, 593 insertions(+), 298 deletions(-) delete mode 100644 LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs delete mode 100644 LehrerApp.Desktop/Views/Groups/WebUntisAbsenceComparisonDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml rename LehrerApp.Desktop/Views/Groups/{WebUntisAbsenceComparisonDialog.axaml.cs => WebUntisLessonAbsenceComparisonDialog.axaml.cs} (54%) create mode 100644 LehrerApp.WebUntis.Tests/WebUntisLessonAbsenceParserTests.cs create mode 100644 LehrerApp.WebUntis/TabSeparatedTextReader.cs create mode 100644 LehrerApp.WebUntis/WebUntisLessonAbsenceParser.cs diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs index 8329a5e..c2afd80 100644 --- a/LehrerApp.Core/Models/LearningGroup.cs +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -20,6 +20,12 @@ public class LearningGroup /// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus. /// public bool RequiresLessonPlanning { get; set; } = true; + /// + /// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des + /// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss + /// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie. + /// + public int? WebUntisLessonId { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/LehrerApp.Core/Services/GroupRolloverService.cs b/LehrerApp.Core/Services/GroupRolloverService.cs index fc035ff..b7be352 100644 --- a/LehrerApp.Core/Services/GroupRolloverService.cs +++ b/LehrerApp.Core/Services/GroupRolloverService.cs @@ -60,6 +60,8 @@ public sealed class GroupRolloverService( IsActive = true, IsOwnClass = source.IsOwnClass, IsDifferentiated = source.IsDifferentiated, + // WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu, + // eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern. }; var sourceWasActive = source.IsActive; diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs index 76d3218..ae2fc05 100644 --- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs +++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs @@ -27,9 +27,9 @@ public sealed record UntisStudentDto(int UntisId, int ExternKey, string ClassNam string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty, string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL); public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList Students); -public sealed record UntisStudentAbsenceDto(int StudentKey, int Date, int StartTime, int EndTime, int AbsentMinutes, - bool Checked, string? AbsenceReason, string? ExcuseStatus, int? SubjectId, IReadOnlyList TeacherIds, - string? StudentGroup); +public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods, + int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime, + string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts); /// Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der /// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server. @@ -107,13 +107,14 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList()); }, token); - public Task> GetAbsencesAsync(DateOnly start, DateOnly end, - CancellationToken token = default) => ExecuteAsync(async client => + public Task> GetLessonAbsencesAsync(int lessonId, + DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client => { - var absences = await client.GetAbsencesAsync(Date(start), Date(end), token); - return (IReadOnlyList)absences.Select(x => new UntisStudentAbsenceDto( - x.StudentKey, x.Date, x.StartTime, x.EndTime, x.AbsentMinutes, x.Checked, x.AbsenceReason, - x.ExcuseStatus, x.SubjectId, x.TeacherIds, x.StudentGroup)).ToList(); + var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token); + return (IReadOnlyList)absences.Select(x => new UntisLessonAbsenceDto( + x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes, + x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses, + x.HandledOn, x.Counts)).ToList(); }, token); private async Task ExecuteAsync(Func> operation, CancellationToken token) diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index da02dca..265dad6 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -878,6 +878,7 @@ public partial class AddGroupDialogViewModel : ObservableObject [ObservableProperty] private bool _isOwnClass; [ObservableProperty] private bool _isDifferentiated; [ObservableProperty] private bool _requiresLessonPlanning = true; + [ObservableProperty] private int? _webUntisLessonId; [ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _gradeLevelError = ""; @@ -917,6 +918,7 @@ public partial class AddGroupDialogViewModel : ObservableObject IsOwnClass = group.IsOwnClass; IsDifferentiated = group.IsDifferentiated; RequiresLessonPlanning = group.RequiresLessonPlanning; + WebUntisLessonId = group.WebUntisLessonId; OnPropertyChanged(nameof(DialogTitle)); OnPropertyChanged(nameof(SaveButtonText)); } @@ -963,6 +965,7 @@ public partial class AddGroupDialogViewModel : ObservableObject Result.IsOwnClass = IsOwnClass; Result.IsDifferentiated = IsDifferentiated; Result.RequiresLessonPlanning = RequiresLessonPlanning; + Result.WebUntisLessonId = WebUntisLessonId; _groups.Save(Result); } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs deleted file mode 100644 index b96cb9f..0000000 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisAbsenceComparisonViewModel.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using LehrerApp.Core.Importing; -using LehrerApp.Core.Interfaces; -using LehrerApp.Core.Models; -using LehrerApp.Desktop.Services; - -namespace LehrerApp.Desktop.ViewModels.Groups; - -public partial class WebUntisAbsenceRow : ObservableObject -{ - public required string StudentName { get; init; } - public required DateOnly Date { get; init; } - public required string TimeLabel { get; init; } - public required string UntisStatus { get; init; } - public required string LocalStatus { get; init; } - public required AttendanceStatus TargetStatus { get; init; } - public required Guid StudentId { get; init; } - public required Guid? SessionId { get; init; } - public string? Reason { get; init; } - public string DateLabel => Date.ToString("dd.MM.yyyy"); - public bool CanApply => SessionId is not null; - [ObservableProperty] private bool _selected; -} - -public partial class WebUntisAbsenceComparisonViewModel : ObservableObject -{ - private readonly LearningGroup _group; - private readonly WebUntisIntegrationService _untis; - private readonly IStudentRepository _students; - private readonly IParticipationSessionRepository _sessions; - private readonly IParticipationRepository _participation; - - public ObservableCollection Rows { get; } = []; - [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2); - [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; - [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; - [ObservableProperty] private bool _busy; - - public WebUntisAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, - IStudentRepository students, IParticipationSessionRepository sessions, - IParticipationRepository participation) - { - _group = group; _untis = untis; _students = students; _sessions = sessions; - _participation = participation; - } - - [RelayCommand] - private async Task Load() - { - 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; } - Busy = true; Rows.Clear(); - try - { - var courseStudents = _students.GetByGroup(_group.Id); - var localSessions = _sessions.GetByGroup(_group.Id) - .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) - .ToDictionary(x => x.Key, x => x.First()); - var linked = courseStudents.Select(student => (Student: student, Key: StudentKey(student))) - .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student); - var absences = await _untis.GetAbsencesAsync(start, end); - var loaded = absences.Where(absence => linked.ContainsKey(absence.StudentKey)) - .Select(absence => (Student: linked[absence.StudentKey], Absence: absence)) - .OrderBy(x => x.Absence.Date).ThenBy(x => x.Student.FullName); - - foreach (var item in loaded) - { - if (!TryDate(item.Absence.Date, out var date)) continue; - localSessions.TryGetValue(date, out var session); - var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, item.Student.Id); - var target = MapStatus(item.Absence.ExcuseStatus); - Rows.Add(new WebUntisAbsenceRow - { - StudentName = item.Student.FullName, StudentId = item.Student.Id, Date = date, - TimeLabel = $"{Time(item.Absence.StartTime)}–{Time(item.Absence.EndTime)}", - UntisStatus = DisplayUntisStatus(item.Absence), - LocalStatus = entry?.Attendance?.ToString() ?? (session is null ? "keine lokale Stunde" : "nicht erfasst"), - TargetStatus = target, SessionId = session?.Id, Reason = item.Absence.AbsenceReason, - Selected = session is not null && entry?.Attendance != target, - }); - } - var withoutKey = courseStudents.Count - linked.Count; - Status = $"{Rows.Count} Untis-Fehlzeiten gefunden; {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar." - + (withoutKey > 0 ? $" {withoutKey} Schüler haben noch keine WebUntis-Kennung." : ""); - } - catch (WebUntisIntegrationException ex) { Status = ex.Message; } - finally { Busy = false; } - } - - [RelayCommand] - private void Apply() - { - var selected = Rows.Where(x => x.Selected && x.SessionId is not null).ToList(); - foreach (var row in selected) - { - var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, row.StudentId) - ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = row.StudentId }; - entry.Attendance = row.TargetStatus; - entry.UpdatedAt = DateTime.UtcNow; - _participation.Save(entry); - } - Status = $"{selected.Count} Anwesenheitsstatus übernommen."; - foreach (var row in selected) row.Selected = false; - } - - private static int? StudentKey(Student student) - { - student.ExternalIds ??= []; - return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value) - && int.TryParse(value, out var key) ? key : null; - } - private static AttendanceStatus MapStatus(string? value) - { - var text = value?.Trim().ToLowerInvariant() ?? ""; - if (text.Contains("unexcused") || text.Contains("unentschuldigt") || text.Contains("nicht entschuldigt")) - return AttendanceStatus.Unexcused; - if (text.Contains("excused") || text.Contains("entschuldigt")) return AttendanceStatus.Excused; - return AttendanceStatus.ExcusePending; - } - private static string DisplayUntisStatus(UntisStudentAbsenceDto absence) => - string.Join(" · ", new[] { absence.ExcuseStatus, absence.AbsenceReason }.Where(x => !string.IsNullOrWhiteSpace(x))) - is { Length: > 0 } text ? text : "offen"; - private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); - private static string Time(int value) => $"{value / 100:00}:{value % 100:00}"; -} diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs new file mode 100644 index 0000000..0d65aa4 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs @@ -0,0 +1,202 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Importing; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +/// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend +/// weggefiltert zu werden) - kann manuell per Auswahlliste gesetzt +/// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen. +public partial class WebUntisLessonAbsenceRow : ObservableObject +{ + public required string UntisStudentName { get; init; } + public required DateOnly Date { get; init; } + public required string TimeLabel { get; init; } + public required string UntisStatus { get; init; } + public required AttendanceStatus TargetStatus { get; init; } + public required IReadOnlyList Candidates { get; init; } + internal Action? OnAssignmentChanged { get; init; } + public string? Reason { get; init; } + public string DateLabel => Date.ToString("dd.MM.yyyy"); + public bool CanApply => SessionId is not null; + + [ObservableProperty] private Student? _assignedStudent; + [ObservableProperty] private string _localStatus = "ohne Zuordnung"; + [ObservableProperty] private Guid? _sessionId; + [ObservableProperty] private bool _selected; + + partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this); +} + +/// Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht (). +/// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte +/// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt. +public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject +{ + private readonly LearningGroup _group; + private readonly WebUntisIntegrationService _untis; + private readonly IStudentRepository _students; + private readonly IParticipationSessionRepository _sessions; + private readonly IParticipationRepository _participation; + + public ObservableCollection Rows { get; } = []; + [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddMonths(-2); + [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; + [ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden."; + [ObservableProperty] private bool _busy; + + public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis, + IStudentRepository students, IParticipationSessionRepository sessions, + IParticipationRepository participation) + { + _group = group; _untis = untis; _students = students; _sessions = sessions; + _participation = participation; + } + + [RelayCommand] + private async Task Load() + { + var lessonId = _group.WebUntisLessonId; + if (lessonId is null) + { + Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " + + "(Gruppe bearbeiten)."; + return; + } + + 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; } + Busy = true; Rows.Clear(); + try + { + var courseStudents = _students.GetByGroup(_group.Id); + var localSessions = _sessions.GetByGroup(_group.Id) + .Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date) + .ToDictionary(x => x.Key, x => x.First()); + // Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt + // per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb + // der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten. + var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student))) + .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student); + var byName = courseStudents + .SelectMany(student => new[] + { + NameKey($"{student.LastName} {student.FirstName}"), + NameKey($"{student.FirstName} {student.LastName}"), + }.Select(key => (Key: key, Student: student))) + .GroupBy(x => x.Key) + .Where(group => group.Select(x => x.Student).Distinct().Count() == 1) + .ToDictionary(group => group.Key, group => group.First().Student); + + var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end); + var ordered = absences + .Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null)) + .Where(x => x.Date is not null) + .OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName); + + void ResolveLocalMatch(WebUntisLessonAbsenceRow row) + { + if (row.AssignedStudent is not { } student) + { + row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false; + return; + } + localSessions.TryGetValue(row.Date, out var session); + var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id); + row.SessionId = session?.Id; + row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance); + row.Selected = session is not null && entry?.Attendance != row.TargetStatus; + } + + foreach (var (absence, date) in ordered) + { + var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent) + ? byKeyStudent + : byName.GetValueOrDefault(NameKey(absence.StudentName)); + var row = new WebUntisLessonAbsenceRow + { + UntisStudentName = absence.StudentName, Date = date!.Value, + TimeLabel = TimeLabel(absence.StartTime, absence.EndTime), + UntisStatus = DisplayUntisStatus(absence), + TargetStatus = MapStatus(absence), Reason = absence.Reason, + Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch, + }; + Rows.Add(row); + row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected + } + + var unresolved = Rows.Count(x => x.AssignedStudent is null); + Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" + + (unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") + + $". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar."; + } + catch (WebUntisIntegrationException ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private void Apply() + { + var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList(); + foreach (var row in selected) + { + var studentId = row.AssignedStudent!.Id; + var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId) + ?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId }; + entry.Attendance = row.TargetStatus; + entry.UpdatedAt = DateTime.UtcNow; + _participation.Save(entry); + } + Status = $"{selected.Count} Anwesenheitsstatus übernommen."; + foreach (var row in selected) row.Selected = false; + } + + private static int? StudentKey(Student student) + { + student.ExternalIds ??= []; + return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value) + && int.TryParse(value, out var key) ? key : null; + } + + // Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht + // dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen + // werden beim Aufbau von `byName` registriert; hier wird nur normalisiert. + private static string NameKey(string value) => value.Trim().ToLowerInvariant(); + + private const int FullLessonMinutes = 45; + + // Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die + // (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in + // Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist + // wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der + // Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung + // oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist + // laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt. + private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) + { + if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass; + if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late; + if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending; + if (absence.ExternKey is null) return AttendanceStatus.ExcusePending; + return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused; + } + + private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) => + absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true; + + // Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung + // (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden. + private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) => + string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason } + .Where(x => !string.IsNullOrWhiteSpace(x))); + + private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); + private static string TimeLabel(int? start, int? end) => + start is null || end is null ? "" : $"{Time(start.Value)}–{Time(end.Value)}"; + private static string Time(int value) => $"{value / 100:00}:{value % 100:00}"; +} diff --git a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml index 4c00d2a..fd63de9 100644 --- a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml @@ -77,6 +77,13 @@ + + + + + diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml index 1c45520..085241a 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml @@ -27,7 +27,7 @@ IsEnabled="{Binding IsEditable}"/>