feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard

- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
  und der letzten Stunde der Einheit vorbelegt statt auf "heute"
  (behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
  den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
  Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
  Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
  Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
  für die zweite Periode) und Berücksichtigung von Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:30:50 +02:00
co-authored by Claude Sonnet 5
parent a36b1d1d40
commit fd509fc921
17 changed files with 585 additions and 23 deletions
@@ -30,10 +30,18 @@ public partial class DashboardViewModel : ObservableObject
private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _sy;
private readonly DashboardSettingsService _dashboardSettings;
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
private const int UpcomingWithinDays = 30;
/// Nutzer-Feedback: "der zeitliche Vorgriff sollte sinnvoll sein" - heute + morgen ist knapp
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
/// um sich abends noch vorzubereiten.
private const int UnplannedLessonsLookaheadDays = 1;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
@@ -53,6 +61,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
@@ -66,6 +75,9 @@ public partial class DashboardViewModel : ObservableObject
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
public Action<Guid>? OnNavigateToLesson { get; set; }
public Action<Guid>? OnNavigateToExam { get; set; }
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks");
@@ -73,6 +85,7 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections");
public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support");
@@ -85,7 +98,9 @@ public partial class DashboardViewModel : ObservableObject
IParticipationRepository participationEntries, IStudentRepository students,
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings)
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -93,6 +108,8 @@ public partial class DashboardViewModel : ObservableObject
_students = students; _documentation = documentation;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions;
LoadDashboardCards();
Load();
}
@@ -157,6 +174,7 @@ public partial class DashboardViewModel : ObservableObject
LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today);
}
@@ -264,6 +282,87 @@ public partial class DashboardViewModel : ObservableObject
}
}
// ── Ungeplante Stunden (Nutzer-Feedback: Erinnerung an Stunden ohne Thema) ────────────────
//
// Nur heute + UnplannedLessonsLookaheadDays (bewusst knapp, siehe Konstante oben) — für jede
// Gruppe mit RequiresLessonPlanning und einem Stundenplan-Slot an diesem Wochentag wird
// geprüft, ob dafür bereits eine Lesson mit Thema existiert. Gruppen ohne eigenen Verlaufsplan
// (Klassenrat, Willkommenskreis) lassen sich in den Stammdaten der Gruppe ausnehmen. Ein Slot,
// für den an diesem Datum ein SubstitutionKind.Cancelled-Eintrag (4.3, "Stundenausfall")
// vorliegt, fällt ganz weg — gleiche Prüfung (Datum + Stundennummer, ohne Gruppenbezug) wie in
// TimetableViewModel.BuildToday.
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
UnplannedLessons.Clear();
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll();
var items = new List<UnplannedLessonItem>();
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
{
var slots = _timetableSlots.GetByGroup(group.Id);
if (slots.Count == 0) continue;
for (var date = today; date <= lastDay; date = date.AddDays(1))
{
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
if (daySlots.Count == 0) continue;
var periodsWithSlot = daySlots.Select(s => s.PeriodNumber).ToHashSet();
var dayLessons = _lessons.GetByGroupAndDate(group.Id, date).ToList();
var cancelledPeriods = _substitutions.GetByDate(date)
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
foreach (var slot in daySlots)
{
if (cancelledPeriods.Contains(slot.PeriodNumber)) continue;
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == slot.PeriodNumber);
if (lesson is not null)
{
if (!string.IsNullOrWhiteSpace(lesson.Topic)) continue;
}
else if (IsCoveredByEarlierDoppelstunde(dayLessons, periodsWithSlot, slot.PeriodNumber))
{
continue;
}
items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today));
}
}
}
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName))
UnplannedLessons.Add(item);
}
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
/// bei einer früheren Stundennummer beginnt — gleiches Prinzip wie
/// PlanningViewModels.LessonDialogViewModel.RecomputeTimeBudget: ausgehend von der Vorperiode
/// wird rückwärts so lange die jeweils vorherige Periode geprüft, wie der Stundenplan dafür
/// noch einen Slot hat. Trifft man dabei auf eine Lesson, entscheidet deren Thema (vorhanden =
/// Doppelstunde bereits geplant); trifft man auf eine Periode ohne Lesson, wird weiter
/// zurückgegangen; bricht die Slot-Kette ab, ohne eine Lesson gefunden zu haben, ist die Periode
/// nicht abgedeckt.</summary>
private static bool IsCoveredByEarlierDoppelstunde(
List<Lesson> dayLessons, HashSet<int> periodsWithSlot, int periodNumber)
{
for (var period = periodNumber - 1; periodsWithSlot.Contains(period); period--)
{
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == period);
if (lesson is not null) return !string.IsNullOrWhiteSpace(lesson.Topic);
}
return false;
}
/// <summary>Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in
/// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein
/// Service-Aufwand für eine dritte Fundstelle.</summary>
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
@@ -330,7 +429,7 @@ public partial class DashboardViewModel : ObservableObject
{
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "alerts" => "Auffälligkeiten",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key,
};
@@ -524,6 +623,8 @@ public partial class DashboardViewModel : ObservableObject
}
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
[RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
{ if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load();
@@ -685,6 +786,16 @@ public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string tit
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
}
public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
{
public Guid GroupId { get; } = groupId;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int PeriodNumber { get; } = periodNumber;
public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
}
public enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
@@ -810,6 +810,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
@@ -848,6 +849,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -893,6 +895,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
_groups.Save(Result);
}
}
@@ -711,9 +711,35 @@ public partial class LessonDialogViewModel : ObservableObject
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
}
else
{
DateText = SuggestNextLessonDate().ToString("dd.MM.yyyy");
}
RecomputeTimes();
}
/// <summary>
/// Vorbelegung für eine neue Stunde: statt des sonst über den Feld-Default eingesetzten
/// heutigen Datums (das an einem beliebigen Wochentag steht und die Doppelstunden-Erkennung
/// in <see cref="RecomputeTimeBudget"/> stillschweigend auf eine Einzelperiode zurückfallen
/// lässt, wenn der Wochentag nicht zufällig passt) der nächste laut Stundenplan (4.3) für
/// diese Gruppe passende Wochentag — ab der letzten Stunde dieser Einheit, oder ab heute, wenn
/// noch keine Stunde in dieser Einheit existiert oder die letzte in der Vergangenheit liegt.
/// Ohne Stundenplan-Einträge für die Gruppe (z.B. Klassenrat) bleibt es beim heutigen Datum.
/// </summary>
private DateOnly SuggestNextLessonDate()
{
var weekdays = _timetableSlots.GetByGroup(_groupId).Select(s => s.Weekday).ToHashSet();
var today = DateOnly.FromDateTime(DateTime.Today);
if (weekdays.Count == 0) return today;
var lastLessonDate = _lessons.GetByUnit(_unitId).Select(l => l.Date)
.DefaultIfEmpty(today.AddDays(-1)).Max();
var candidate = lastLessonDate >= today ? lastLessonDate.AddDays(1) : today;
while (!weekdays.Contains(candidate.DayOfWeek)) candidate = candidate.AddDays(1);
return candidate;
}
[RelayCommand]
private void AddPhase()
{
@@ -46,6 +46,7 @@ public static class TaskCategoryDisplay
TaskCategory.Admin => "Verwaltung",
TaskCategory.Meeting => "Besprechung",
TaskCategory.Other => "Sonstiges",
TaskCategory.Teaching => "Unterricht",
_ => c.ToString(),
};
@@ -333,6 +334,15 @@ public partial class TimeTrackingViewModel : ObservableObject
{
private readonly ITimeEntryRepository _entries;
private readonly IWorkTaskRepository _tasks;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule;
// 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
// Puffer für den Unterrichtszeit-Vorschlag. Der Vorschlag füllt nur den Nacherfassen-Dialog
// vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
private const int BufferBeforeFirstPeriodMinutes = 15;
private const int BufferAfterLastPeriodMinutes = 10;
public const string NoTaskOption = "Keine Aufgabe";
@@ -351,12 +361,24 @@ public partial class TimeTrackingViewModel : ObservableObject
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
/// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
/// "Unterrichtszeit übernehmen"-Button überhaupt sichtbar ist.
public bool HasTeachingTimeSuggestionToday => ComputeTodaysTeachingWindow() is not null;
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks)
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
/// Wie OnAddEntry, aber öffnet den Dialog mit vorbefüllter Kategorie "Unterricht" und den
/// laut Stundenplan/Stundenraster vorgeschlagenen Beginn-/Ende-Zeiten - eine echte Bestätigung
/// 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 TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
{
_entries = entries;
_tasks = tasks;
_timetableSlots = timetableSlots;
_periodSchedule = periodSchedule;
Load();
}
@@ -442,6 +464,38 @@ public partial class TimeTrackingViewModel : ObservableObject
Refresh();
}
[RelayCommand]
private async Task SuggestTeachingTime()
{
if (OnSuggestTeachingTime is null || ComputeTodaysTeachingWindow() is not var (start, end)) return;
var result = await OnSuggestTeachingTime(start, end);
if (result is null) return;
_entries.Save(result);
Refresh();
}
/// <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
/// Puffer erweitert. Ohne Stundenplan-Eintrag heute oder ohne im Stundenraster hinterlegte
/// Zeiten gibt es keinen Vorschlag (null) statt einer erfundenen Zeit.
/// </summary>
private (TimeOnly Start, TimeOnly End)? ComputeTodaysTeachingWindow()
{
var today = DateOnly.FromDateTime(DateTime.Today).DayOfWeek;
var periodTimes = _timetableSlots.GetAll()
.Where(s => s.Weekday == today)
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
.Where(t => t is not null)
.Select(t => t!.Value)
.ToList();
if (periodTimes.Count == 0) return null;
var start = periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes);
var end = periodTimes.Max(t => t.End).AddMinutes(BufferAfterLastPeriodMinutes);
return (start, end);
}
[RelayCommand]
private void DeleteEntry(TimeEntryListItem? item)
{