From 709ea88c2a8de54b870967aea9f3b972c45e983c Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 6 Sep 2026 13:51:17 +0200 Subject: [PATCH] Arbeitszeitnacherfassung --- .../Services/DashboardSettingsService.cs | 3 +- .../DashboardViewModelTests.cs | 1 + .../WorkloadViewModelTests.cs | 45 ++++++++++ .../ViewModels/DashboardViewModel.cs | 2 +- .../ViewModels/Workload/WorkloadViewModels.cs | 82 ++++++++++++++++++- .../Views/Dashboard/DashboardView.axaml | 2 +- .../Views/Workload/TimeTrackingView.axaml | 24 ++++++ .../Views/Workload/TimeTrackingView.axaml.cs | 5 +- TODO.md | 3 + 9 files changed, 161 insertions(+), 6 deletions(-) diff --git a/LehrerApp.Core/Services/DashboardSettingsService.cs b/LehrerApp.Core/Services/DashboardSettingsService.cs index 04b6278..2476f04 100644 --- a/LehrerApp.Core/Services/DashboardSettingsService.cs +++ b/LehrerApp.Core/Services/DashboardSettingsService.cs @@ -14,9 +14,8 @@ public sealed class DashboardSettingsService { public static readonly string[] DefaultCardOrder = [ - "today", "tasks", "calendar", "excuses", "upcoming", + "today", "tasks", "missingteachingtime", "calendar", "excuses", "upcoming", "corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload", - "missingteachingtime", ]; private readonly string _configPath; diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index d2f958c..95c1209 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -154,6 +154,7 @@ public sealed class DashboardViewModelTests Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible); + Assert.Equal(2, vm.AttentionCount); // fehlende Unterrichtszeit + bereits bestehende ungeplante Stunde } [Fact] diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs index d03e9b3..5bd4d5a 100644 --- a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs @@ -572,6 +572,51 @@ public sealed class TimeTrackingViewModelTests Assert.False(called); } + + [Fact] + public void MissingTeachingTime_VergangenerUnterrichtstag_WirdInZeiterfassungAngezeigt() + { + var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 }); + var periodSchedule = TestSupport.BuildPeriodScheduleService(); + periodSchedule.SetPeriods([new PeriodTimeEntry + { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); + + var vm = new TimeTrackingViewModel( + new FakeTimeEntries(), new FakeWorkTasks(), slots, periodSchedule); + + var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); + Assert.Equal(new TimeOnly(7, 45), gap.WindowStart); + Assert.Equal(new TimeOnly(8, 55), gap.WindowEnd); + } + + [Fact] + public async Task AddMissingTeachingTime_SpeichertVorgefuelltenTagUndEntferntIhnAusOffenerListe() + { + var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 }); + var periodSchedule = TestSupport.BuildPeriodScheduleService(); + periodSchedule.SetPeriods([new PeriodTimeEntry + { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); + var entries = new FakeTimeEntries(); + var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule); + var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); + vm.OnAddMissingTeachingTime = item => Task.FromResult(new TimeEntry + { + Date = item.Date, + Category = TaskCategoryDisplay.Label(TaskCategory.Teaching), + StartTime = item.WindowStart, + EndTime = item.WindowEnd, + DurationMinutes = (int)(item.WindowEnd - item.WindowStart).TotalMinutes, + }); + + await vm.AddMissingTeachingTimeCommand.ExecuteAsync(gap); + + Assert.Single(entries.GetByDate(pastDay)); + Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); + } } public sealed class AddTimeEntryDialogViewModelTests diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 685f063..c93f237 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -137,7 +137,7 @@ public partial class DashboardViewModel : ObservableObject public int OpenTaskCount => OpenTasks.Count; public int UpcomingCount => UpcomingDates.Count; public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count - + OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count; + + OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count + MissingTeachingTimeEntries.Count; public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden"; public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben"; public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs index cc3610d..2148276 100644 --- a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs @@ -467,6 +467,10 @@ public partial class TimeTrackingViewModel : ObservableObject private readonly IWorkTaskRepository _tasks; private readonly ITimetableSlotRepository _timetableSlots; private readonly PeriodScheduleService _periodSchedule; + private readonly ISchoolHolidayRepository? _schoolHolidays; + private readonly PublicHolidayService? _publicHolidays; + private readonly SchoolCalendarSettingsService? _calendarSettings; + private readonly ISubstitutionEntryRepository? _substitutions; // Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch // nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible @@ -474,6 +478,8 @@ public partial class TimeTrackingViewModel : ObservableObject // vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime). private const int BufferBeforeFirstPeriodMinutes = 15; private const int BufferAfterLastPeriodMinutes = 10; + private const int MissingTeachingTimeLookbackDays = 14; + private const int MissingTeachingTimeTodayDelayMinutes = 30; public const string NoTaskOption = "Keine Aufgabe"; @@ -490,6 +496,7 @@ public partial class TimeTrackingViewModel : ObservableObject public ObservableCollection WeekEntries { get; } = []; public ObservableCollection CategorySummaries { get; } = []; + public ObservableCollection MissingTeachingTimeEntries { get; } = []; public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche"; /// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der @@ -502,14 +509,22 @@ public partial class TimeTrackingViewModel : ObservableObject /// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer- /// Konstanten oben). public Func>? OnSuggestTeachingTime { get; set; } + public Func>? OnAddMissingTeachingTime { get; set; } public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks, - ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule) + ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule, + ISchoolHolidayRepository? schoolHolidays = null, PublicHolidayService? publicHolidays = null, + SchoolCalendarSettingsService? calendarSettings = null, + ISubstitutionEntryRepository? substitutions = null) { _entries = entries; _tasks = tasks; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; + _schoolHolidays = schoolHolidays; + _publicHolidays = publicHolidays; + _calendarSettings = calendarSettings; + _substitutions = substitutions; Load(); } @@ -550,6 +565,7 @@ public partial class TimeTrackingViewModel : ObservableObject minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0)); } + RefreshMissingTeachingTime(today); OnPropertyChanged(nameof(TotalWeekMinutesDisplay)); } @@ -605,6 +621,59 @@ public partial class TimeTrackingViewModel : ObservableObject Refresh(); } + [RelayCommand] + private async Task AddMissingTeachingTime(TeachingTimeGapItem? item) + { + if (item is null || OnAddMissingTeachingTime is null) return; + var result = await OnAddMissingTeachingTime(item); + if (result is null) return; + _entries.Save(result); + Refresh(); + } + + private void RefreshMissingTeachingTime(DateOnly today) + { + MissingTeachingTimeEntries.Clear(); + var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays); + var timetableSlots = _timetableSlots.GetAll(); + var schoolHolidays = _schoolHolidays?.GetAll() ?? []; + var publicHolidayDates = _publicHolidays is not null && _calendarSettings is not null + ? Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1) + .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) + .Select(h => h.Date).ToHashSet() + : []; + var nowTime = TimeOnly.FromDateTime(DateTime.Now); + var teachingCategory = TaskCategoryDisplay.Label(TaskCategory.Teaching); + + for (var date = firstDay; date <= today; date = date.AddDays(1)) + { + if (publicHolidayDates.Contains(date) + || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate)) + continue; + + var daySlots = timetableSlots.Where(s => s.Weekday == date.DayOfWeek).ToList(); + if (daySlots.Count == 0) continue; + + var cancelledPeriods = (_substitutions?.GetByDate(date) ?? []) + .Where(s => s.Kind == SubstitutionKind.Cancelled) + .Select(s => s.PeriodNumber).ToHashSet(); + var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber)) + .Select(s => _periodSchedule.GetTimes(s.PeriodNumber)) + .Where(t => t is not null).Select(t => t!.Value).ToList(); + if (periodTimes.Count == 0) continue; + + var lastPeriodEnd = periodTimes.Max(t => t.End); + if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes)) + continue; + if (_entries.GetByDate(date).Any(e => e.Category == teachingCategory)) continue; + + MissingTeachingTimeEntries.Add(new TeachingTimeGapItem( + date, + periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes), + lastPeriodEnd.AddMinutes(BufferAfterLastPeriodMinutes))); + } + } + /// /// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht /// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten @@ -647,6 +716,17 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle) public string Description => Model.Description ?? ""; } +public sealed class TeachingTimeGapItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd) +{ + private static readonly CultureInfo De = new("de-DE"); + + public DateOnly Date { get; } = date; + public TimeOnly WindowStart { get; } = windowStart; + public TimeOnly WindowEnd { get; } = windowEnd; + public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De); + public string TimeDisplay { get; } = $"{windowStart:HH:mm}–{windowEnd:HH:mm} Uhr"; +} + public class CategoryTimeSummary(string category, int minutes, double barFraction) { public string Category { get; } = category; diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index d6782e5..997b313 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -132,7 +132,7 @@ - + + + + + + + + + + + +