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
+7
View File
@@ -13,6 +13,13 @@ public class LearningGroup
public bool IsActive { get; set; } = true;
public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; }
/// <summary>
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
/// </summary>
public bool RequiresLessonPlanning { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+1 -1
View File
@@ -103,6 +103,6 @@ public class TimeEntry
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
public enum WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly }
@@ -15,7 +15,7 @@ public sealed class DashboardSettingsService
public static readonly string[] DefaultCardOrder =
[
"today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "alerts", "attendance", "support", "groups",
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
];
private readonly string _configPath;
@@ -26,14 +26,23 @@ public sealed class DashboardViewModelTests
return new DashboardSettingsService(tempPath);
}
private static SchoolCalendarSettingsService NewCalendarSettings()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SchoolCalendarSettingsService(tempPath);
}
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null)
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null)
{
var lessons = new FakeLessons();
lessons ??= new FakeLessons();
lessons.Add(lesson);
return new DashboardViewModel(
new FakeGroups([group]), new FakeSubjects([]), lessons,
@@ -42,7 +51,9 @@ public sealed class DashboardViewModelTests
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings());
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries());
}
[Fact]
@@ -227,4 +238,119 @@ public sealed class DashboardViewModelTests
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
}
// ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
[Fact]
public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
// "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
// Vorgriff fällt und den Test verfälscht.
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var vm = BuildVm(group, pastLesson, slots: slots);
var item = Assert.Single(vm.UnplannedLessons);
Assert.Equal(group.Id, item.GroupId);
Assert.Equal(1, item.PeriodNumber);
Assert.Equal("Heute", item.DateDisplay);
}
[Fact]
public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redoxreaktionen" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var vm = BuildVm(group, pastLesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_Feiertag_WirdUebersprungen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Equal(2, vm.UnplannedLessons.Count);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
}
[Fact]
public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
Assert.Empty(vm.UnplannedLessons);
}
}
+9
View File
@@ -1,5 +1,6 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
@@ -47,6 +48,14 @@ public static class TestSupport
Directory.CreateDirectory(tempPath);
return new EventQueue(Path.Combine(tempPath, "queue.db"));
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static PeriodScheduleService BuildPeriodScheduleService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new PeriodScheduleService(tempPath);
}
}
public class FakeStudents(List<Student> all) : IStudentRepository
@@ -388,4 +388,53 @@ public sealed class LessonDialogViewModelTests
Assert.False(vm.HasTimeBudgetInfo);
}
// ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf
// "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode
// zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ────────────────────
[Fact]
public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer
Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText);
}
[Fact]
public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag
var lessons = new FakeLessons();
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 });
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
// Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
Assert.Equal("13.01.2099", vm.DateText);
}
[Fact]
public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 });
var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
Assert.Equal(weekday, suggested.DayOfWeek);
}
}
@@ -1,3 +1,4 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Workload;
@@ -288,14 +289,16 @@ public sealed class AddEditWorkTaskDialogViewModelTests
public sealed class TimeTrackingViewModelTests
{
private static TimeTrackingViewModel BuildVm(ITimeEntryRepository entries, IWorkTaskRepository tasks,
FakeTimetableSlots? slots = null) =>
new(entries, tasks, slots ?? new FakeTimetableSlots(), TestSupport.BuildPeriodScheduleService());
[Fact]
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
{
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
{
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
};
var vm = BuildVm(entries, new FakeWorkTasks());
vm.SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction);
vm.StartTimerCommand.Execute(null);
Assert.True(vm.IsTimerRunning);
@@ -314,7 +317,7 @@ public sealed class TimeTrackingViewModelTests
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
var tasks = new FakeWorkTasks();
tasks.Add(task);
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
var vm = BuildVm(new FakeTimeEntries(), tasks);
vm.SelectedTimerTask = task;
@@ -328,7 +331,7 @@ public sealed class TimeTrackingViewModelTests
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
Assert.Single(vm.WeekEntries);
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
@@ -343,7 +346,7 @@ public sealed class TimeTrackingViewModelTests
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
Assert.Equal("50 min", korrektur.MinutesDisplay);
@@ -356,7 +359,7 @@ public sealed class TimeTrackingViewModelTests
{
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
@@ -367,7 +370,7 @@ public sealed class TimeTrackingViewModelTests
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
{
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
@@ -375,6 +378,64 @@ public sealed class TimeTrackingViewModelTests
Assert.Single(vm.WeekEntries);
}
// ── Unterrichtszeit-Vorschlag (Nutzer-Feedback: Zeiterfassung soll bei der Unterrichtszeit
// nachfragen statt sie komplett automatisch zu erfassen) ────────────────────────────────────
[Fact]
public void HasTeachingTimeSuggestionToday_OhneStundenplanHeute_IstFalse()
{
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks()); // leere FakeTimetableSlots
Assert.False(vm.HasTeachingTimeSuggestionToday);
}
[Fact]
public async Task SuggestTeachingTime_SchlaegtBeginnMitPufferVorErsterUndNachLetzterPeriodeVor()
{
var today = DateTime.Today.DayOfWeek;
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 1 });
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 2 });
var periodSchedule = TestSupport.BuildPeriodScheduleService();
periodSchedule.SetPeriods([
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
]);
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
Assert.True(vm.HasTeachingTimeSuggestionToday);
(TimeOnly Start, TimeOnly End)? suggested = null;
vm.OnSuggestTeachingTime = (start, end) =>
{
suggested = (start, end);
return Task.FromResult<TimeEntry?>(new TimeEntry
{
Category = "Unterricht", Date = DateOnly.FromDateTime(DateTime.Today),
StartTime = start, EndTime = end, DurationMinutes = (int)(end - start).TotalMinutes,
});
};
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
Assert.Equal(new TimeOnly(7, 35), suggested!.Value.Start); // 7:50 - 15 Min Puffer
Assert.Equal(new TimeOnly(9, 30), suggested.Value.End); // 9:20 + 10 Min Puffer
Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
}
[Fact]
public async Task SuggestTeachingTime_OhneStundenplanHeute_TutNichts()
{
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks());
var called = false;
vm.OnSuggestTeachingTime = (_, _) => { called = true; return Task.FromResult<TimeEntry?>(null); };
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
Assert.False(called);
}
}
public sealed class AddTimeEntryDialogViewModelTests
+1
View File
@@ -101,6 +101,7 @@ public class App : Application
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
// StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>();
@@ -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)
{
@@ -54,7 +54,7 @@
</ItemsControl>
</Border>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
@@ -415,6 +415,36 @@
</StackPanel>
</Border>
<!-- Ungeplante Stunden -->
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
IsVisible="{Binding UnplannedCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNGEPLANTE STUNDEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding UnplannedLessons}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UnplannedLessonItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUnplannedLessonCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Display}" FontSize="13"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Opacity="0.6"/>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine ungeplanten Stunden." Classes="emptyhint"
IsVisible="{Binding !UnplannedLessons.Count}"/>
</StackPanel>
</Border>
<!-- Auffälligkeiten (9.5) -->
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
@@ -74,6 +74,9 @@
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
<CheckBox Content="Benötigt Unterrichtsplanung" IsChecked="{Binding RequiresLessonPlanning}"
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung &quot;Ungeplante Stunden&quot; aus."/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -66,9 +66,13 @@
</Border>
<!-- Einträge / Nacherfassung (6.2.2) -->
<Grid ColumnDefinitions="*,Auto">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content=" Nacherfassen" Command="{Binding AddEntryCommand}"/>
<Button Grid.Column="1" Content="🕓 Unterrichtszeit heute übernehmen" Margin="0,0,8,0"
Command="{Binding SuggestTeachingTimeCommand}"
IsVisible="{Binding HasTeachingTimeSuggestionToday}"
ToolTip.Tip="Schlägt Beginn/Ende anhand des heutigen Stundenplans vor (mit etwas Puffer davor/danach) - im Dialog vor dem Speichern noch anpassbar."/>
<Button Grid.Column="2" Content=" Nacherfassen" Command="{Binding AddEntryCommand}"/>
</Grid>
<ItemsControl ItemsSource="{Binding WeekEntries}">
@@ -14,10 +14,15 @@ public partial class TimeTrackingView : UserControl
{
base.OnDataContextChanged(e);
if (DataContext is TimeTrackingViewModel vm)
vm.OnAddEntry = ShowAddEntryDialog;
{
vm.OnAddEntry = () => ShowAddEntryDialog();
vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht",
prefillStart: start, prefillEnd: end);
}
}
private async Task<TimeEntry?> ShowAddEntryDialog()
private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null,
TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
@@ -25,6 +30,9 @@ public partial class TimeTrackingView : UserControl
var tasks = App.Services.GetRequiredService<IWorkTaskRepository>()
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks);
if (prefillCategory is not null) vm.SelectedCategory = prefillCategory;
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 };
await dialog.ShowDialog<bool>(owner);
return vm.Result;
+70
View File
@@ -460,6 +460,20 @@ Redesign:
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
**Nachtrag — Datum-Vorbelegung beim manuellen " Stunde"-Dialog:** Nutzer-Bug-Report: eine
manuell angelegte Doppelstunde zeigte immer 45 statt 90 Minuten Zeitbedarf. Ursache war kein
Fehler in der Doppelstunden-Erkennung (Nachtrag zu 4.2.2 oben) selbst, sondern dass `DateText`
im `LessonDialogViewModel` beim Neuanlegen immer auf "heute" vorbelegt war — traf dieses Datum
nicht auf den Wochentag der eingetragenen Stundennummer im Stundenplan, lief die
weekday-basierte Nachschlage-Logik für die Folgeperiode ins Leere und die Erkennung blieb
stumm bei 45 Minuten, ohne dass das im UI ersichtlich war. `LessonDialogViewModel.
SuggestNextLessonDate()` ersetzt jetzt den "heute"-Default beim Neuanlegen: sie ermittelt aus
`TimetableSlot` alle Wochentage, an denen die Gruppe laut Stundenplan Unterricht hat, nimmt das
späteste bereits existierende `Lesson.Date` der Einheit (oder heute, falls die Einheit noch
leer ist) und rollt von dort vorwärts auf den nächsten passenden Wochentag. Fehlt ein
Stundenplan-Eintrag für die Gruppe, bleibt "heute" als Fallback erhalten (keine Verhaltens-
änderung für Gruppen ohne Stundenplan).
### 4.3 Stundenplan
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
@@ -1101,6 +1115,22 @@ Auswertung (6.3.3), der am noch nicht existierenden Kapitel 11 (Export-Infrastru
Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
`ITimeEntryRepository`.
**Nachtrag — Komfort-Funktion "Unterrichtszeit heute übernehmen":** Nutzer-Wunsch: Unterrichtszeit
soll nicht komplett automatisch erfasst werden ("weiß nicht, ob das rechtlich sinnvoll ist"),
aber ein Vorschlag mit Rückfrage ist erwünscht — man beginnt vor der ersten Stunde und geht nicht
sofort nach der letzten. Neuer Button "🕓 Unterrichtszeit heute übernehmen" in
`TimeTrackingView` (nur sichtbar, wenn die Gruppe heute laut Stundenplan überhaupt Unterricht
hat — `TimeTrackingViewModel.HasTeachingTimeSuggestionToday`). `ComputeTodaysTeachingWindow()`
ermittelt aus allen `TimetableSlot`-Einträgen des heutigen Wochentags (`ITimetableSlotRepository`)
und dem Stundenraster (6.2/4.2, `PeriodScheduleService`) die früheste Start- und späteste
Endzeit des Tages, zieht einen festen Puffer davor (`BufferBeforeFirstPeriodMinutes = 15`) und
danach (`BufferAfterLastPeriodMinutes = 10`) ab/dazu. Klick öffnet den bestehenden
`AddTimeEntryDialog` (6.2.2) mit Kategorie "Unterricht" sowie Start/Ende vorbefüllt — bewusst
weiterhin ein normaler, vom Nutzer bestätigter Nacherfassungs-Dialog, keine automatische
Buchung ohne Blick darauf. Neuer `TaskCategory.Teaching`-Wert (`TaskCategoryDisplay.Label`:
"Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu
verschieben.
### 6.3 Auswertung
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat
@@ -1294,6 +1324,46 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
werden, sobald dafür ein eigenes Termine-Modell existiert.
- [x] **9.9** Kachel "Ungeplante Stunden": erinnert an Stunden ohne Thema, für die noch kein
Verlaufsplan existiert. Nutzer-Wunsch: der zeitliche Vorgriff soll sinnvoll begrenzt sein
(morgens sollte wenigstens ein Stundenthema bereits bekannt sein) und einzelne Gruppen ohne
inhaltlichen Verlaufsplan (Klassenrat, Willkommenskreis) sollen sich ausnehmen lassen.
**Umsetzung:** `DashboardViewModel.LoadUnplannedLessons` läuft für jede Gruppe mit
`LearningGroup.RequiresLessonPlanning == true` (neues Feld, Default `true` — bestehende
Gruppen erhalten es beim LiteDB-Deserialisieren automatisch, keine explizite Migration
nötig) über die `TimetableSlot`-Einträge von heute bis morgen
(`UnplannedLessonsLookaheadDays = 1`, Nutzer hat sich im Dialog explizit für "Heute +
morgen" statt eines längeren Vorgriffs entschieden) und meldet jeden Termin, zu dem entweder
keine `Lesson` existiert oder deren `Topic` leer ist. Ferientage/Feiertage werden wie überall
sonst übersprungen (`IsFreeDay`, dieselbe Prüfung wie in `TimetableViewModel`/
`GenerateLessonSeriesDialogViewModel` — bewusst dupliziert statt in einen gemeinsamen
Service extrahiert, konsistent mit dem bestehenden Muster). Klick auf einen Eintrag springt
in die Lerngruppe, Tab "Planung" (`OnNavigateToUnplannedLesson`,
`NavigateToGroupDetail(id, 6)`), damit das Thema direkt ergänzt werden kann — bewusst
anderes Sprungziel als die "Heutige Stunden"-Kachel (9.2, Tab "Mitarbeit"). Deaktivierbar je
Gruppe über eine neue Checkbox "Benötigt Unterrichtsplanung" im Gruppen-Stammdaten-Dialog.
**Nachtrag zu 9.9 (Bugfix Doppelstunden-Erkennung):** Nutzer-Bug-Report: eine als Doppelstunde
geplante 3./4. Stunde (eine `Lesson` mit `LessonNumber = 3` und Thema, die laut Stundenraster
90 Minuten abdeckt, siehe Nachtrag zu 4.2.2) wurde auf dem Dashboard trotzdem als "4. Stunde noch
ungeplant" gemeldet, weil `LoadUnplannedLessons` je `TimetableSlot` stur auf eine `Lesson` mit
exakt derselben `LessonNumber` prüfte — für Periode 4 gibt es bei einer Doppelstunde aber bewusst
keine eigene `Lesson`. Neue Hilfsmethode `IsCoveredByEarlierDoppelstunde` wendet dieselbe
Rückwärts-Erkennung wie `LessonDialogViewModel.RecomputeTimeBudget` an: ausgehend von der
Vorperiode wird so lange rückwärts geprüft, wie der Stundenplan dort ebenfalls einen Slot hat;
trifft man auf eine `Lesson` mit Thema, gilt die Periode als abgedeckt, trifft man auf eine
`Lesson` ohne Thema, bricht die Suche ab (diese Periode bleibt eine eigene, ungeplante Stunde).
Eine eigene, direkt zugeordnete `Lesson` ohne Thema wird also weiterhin gemeldet — nur die
implizit mitabgedeckte Folgeperiode einer bereits geplanten Doppelstunde nicht mehr.
**Nachtrag zu 9.9 (Bugfix Stundenausfall):** Zweiter Nutzer-Bug-Report: eine laut Stundenplan
regulär stattfindende, aber per Vertretungs-Dialog als `SubstitutionKind.Cancelled` eingetragene
Stunde (4.3, "Stundenausfall") wurde trotzdem als ungeplant gemeldet, obwohl sie an diesem Tag
gar nicht stattfindet. `LoadUnplannedLessons` prüft jetzt zusätzlich `ISubstitutionEntryRepository.
GetByDate(date)` auf `Cancelled`-Einträge und überspringt betroffene Stundennummern vollständig —
gleiche Abfrage (Datum + Stundennummer, bewusst ohne Gruppenbezug, siehe `SubstitutionEntry.
PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des
Stundenplans.
---