diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs index 70d8605..8329a5e 100644 --- a/LehrerApp.Core/Models/LearningGroup.cs +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -13,6 +13,13 @@ public class LearningGroup public bool IsActive { get; set; } = true; public bool IsOwnClass { get; set; } public bool IsDifferentiated { get; set; } + /// + /// 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. + /// + public bool RequiresLessonPlanning { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs index 450b7d0..430065c 100644 --- a/LehrerApp.Core/Models/Workload.cs +++ b/LehrerApp.Core/Models/Workload.cs @@ -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 } diff --git a/LehrerApp.Core/Services/DashboardSettingsService.cs b/LehrerApp.Core/Services/DashboardSettingsService.cs index 14fda94..f11aa93 100644 --- a/LehrerApp.Core/Services/DashboardSettingsService.cs +++ b/LehrerApp.Core/Services/DashboardSettingsService.cs @@ -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; diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index e54e76a..3cfc311 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -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); + } } diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 449168b..a8594b0 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -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 , 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 all) : IStudentRepository diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs index dfb69a8..6788c61 100644 --- a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs @@ -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); + } } diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs index 7f3421f..7be2b94 100644 --- a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs @@ -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( 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(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(null); }; + + await vm.SuggestTeachingTimeCommand.ExecuteAsync(null); + + Assert.False(called); + } } public sealed class AddTimeEntryDialogViewModelTests diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index cbe7916..41c380a 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -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(); diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index b7552cd..9f4c393 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -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 SupportPlanReviews { get; } = []; public ObservableCollection UpcomingDates { get; } = []; public ObservableCollection OpenCorrections { get; } = []; + public ObservableCollection UnplannedLessons { get; } = []; public ObservableCollection Alerts { get; } = []; public ObservableCollection SelectedDayEvents { get; } = []; public ObservableCollection DashboardCards { get; } = []; @@ -66,6 +75,9 @@ public partial class DashboardViewModel : ObservableObject // Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt. public Action? OnNavigateToLesson { get; set; } public Action? 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? 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 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(); + 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); + } + + /// 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. + private static bool IsCoveredByEarlierDoppelstunde( + List dayLessons, HashSet 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; + } + + /// Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in + /// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein + /// Service-Aufwand für eine dritte Fundstelle. + private static bool IsFreeDay(DateOnly date, List schoolHolidays, HashSet publicHolidayDates) => + publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate); + // ── Auffälligkeiten (9.5) ──────────────────────────────────────────────── private void LoadAlerts(IReadOnlyDictionary 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, diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index bcd48cf..dd6d5db 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -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); } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 2922c0b..41d722b 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -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(); } + /// + /// 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 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. + /// + 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() { diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs index 6d11446..32cd5cb 100644 --- a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs @@ -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 CategorySummaries { get; } = []; public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche"; - public Func>? 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>? 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>? 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(); + } + + /// + /// 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. + /// + 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) { diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 1c70d9c..1a7e847 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -54,7 +54,7 @@ - + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml index a1e0b99..897430e 100644 --- a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml +++ b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml @@ -66,9 +66,13 @@ - + -