Arbeitszeitnacherfassung
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-06 13:51:17 +02:00
parent 73453fd88d
commit 709ea88c2a
9 changed files with 161 additions and 6 deletions
@@ -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;
@@ -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]
@@ -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<TimeEntry?>(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
@@ -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";
@@ -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<TimeEntryListItem> WeekEntries { get; } = [];
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public ObservableCollection<TeachingTimeGapItem> 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<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
public Func<TeachingTimeGapItem, Task<TimeEntry?>>? 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)));
}
}
/// <summary>
/// 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;
@@ -132,7 +132,7 @@
<TextBlock Text="HEUTE UND HANDLUNGSBEDARF" FontSize="11" FontWeight="Bold" Opacity="0.5"
Margin="2,2,0,-8"/>
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<Grid ColumnDefinitions="3*,2*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
@@ -65,6 +65,30 @@
</StackPanel>
</Border>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
<StackPanel Spacing="10">
<TextBlock Text="Unterrichtszeit nacherfassen" FontWeight="SemiBold" FontSize="14"/>
<TextBlock Text="Arbeitstage der letzten 14 Tage, an denen laut Stundenplan Unterricht war, aber noch keine Unterrichtszeit erfasst wurde."
FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TeachingTimeGapItem">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding TimeDisplay}" FontSize="12" Opacity="0.65"
Margin="12,0" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content="Erfassen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:TimeTrackingViewModel)DataContext).AddMissingTeachingTimeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Unterrichtszeit offen." Opacity="0.6" FontSize="12"
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
</StackPanel>
</Border>
<!-- Einträge / Nacherfassung (6.2.2) -->
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
@@ -18,11 +18,13 @@ public partial class TimeTrackingView : UserControl
vm.OnAddEntry = () => ShowAddEntryDialog();
vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht",
prefillStart: start, prefillEnd: end);
vm.OnAddMissingTeachingTime = item => ShowAddEntryDialog(prefillCategory: "Unterricht",
prefillDate: item.Date, prefillStart: item.WindowStart, prefillEnd: item.WindowEnd);
}
}
private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null,
TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
DateOnly? prefillDate = null, TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
@@ -31,6 +33,7 @@ public partial class TimeTrackingView : UserControl
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks);
if (prefillCategory is not null) vm.SelectedCategory = prefillCategory;
if (prefillDate is { } date) vm.DateText = date.ToString("dd.MM.yyyy");
if (prefillStart is { } s) vm.StartTimeText = s.ToString("HH:mm");
if (prefillEnd is { } e) vm.EndTimeText = e.ToString("HH:mm");
var dialog = new AddTimeEntryDialog { DataContext = vm };
+3
View File
@@ -2792,6 +2792,9 @@ bewusst erst 30 Minuten nach dem laut Stundenplan letzten Unterrichtsende
auftaucht. Klick auf "Erfassen" öffnet denselben `AddTimeEntryDialog` wie der bestehende
Tages-Vorschlag, vorbelegt mit Datum, Kategorie "Unterricht" und dem für diesen Tag berechneten
Zeitfenster — auch hier bleibt die Bestätigung im Dialog Pflicht, nichts wird automatisch gebucht.
Die gleiche Liste steht zusätzlich direkt im Tab **Arbeitszeit → Zeiterfassung**, damit die
Nacherfassung nicht nur über das Dashboard auffindbar ist; offene Tage zählen außerdem im
Dashboard-Tagesfokus als Handlungsbedarf.
### 6.3 Auswertung
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —