@@ -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 };
|
||||
|
||||
@@ -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) —
|
||||
|
||||
Reference in New Issue
Block a user