From dade41ee8d71af64c5a9055dd56fe41affa3c03b Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 30 Aug 2026 00:53:32 +0200 Subject: [PATCH] Stundenplan verbesserung --- LehrerApp.Core/Models/Planning.cs | 5 +- .../Services/LessonSchedulingService.cs | 99 +++++++++++++++++ .../LessonSchedulingServiceTests.cs | 90 +++++++++++++++ .../TimetableViewModelTests.cs | 18 ++- .../Groups/GroupOverviewViewModel.cs | 2 +- .../ViewModels/Groups/PlanningViewModels.cs | 91 +++++++++------ .../TimetableLessonWorkflowViewModels.cs | 83 ++++++++++++++ .../ViewModels/Planning/TimetableViewModel.cs | 92 +++++++++++---- .../Views/Groups/MoveLessonDialog.axaml | 22 +++- .../Views/Groups/PlanningTabView.axaml | 3 +- .../Views/Groups/PlanningTabView.axaml.cs | 3 +- .../Planning/TimetableUnitPickerDialog.axaml | 43 +++++++ .../TimetableUnitPickerDialog.axaml.cs | 19 ++++ .../Views/Planning/TimetableView.axaml | 9 ++ .../Views/Planning/TimetableView.axaml.cs | 105 +++++++++++++++++- TODO.md | 30 ++--- 16 files changed, 639 insertions(+), 75 deletions(-) create mode 100644 LehrerApp.Core/Services/LessonSchedulingService.cs create mode 100644 LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Planning/TimetableLessonWorkflowViewModels.cs create mode 100644 LehrerApp.Desktop/Views/Planning/TimetableUnitPickerDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Planning/TimetableUnitPickerDialog.axaml.cs diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 79b931b..0accca5 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -100,7 +100,10 @@ public class LessonPhaseStep } public enum UnitStatus { Planned, Active, Completed } -public enum LessonStatus { Planned, Conducted } +// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen: +// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit +// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden. +public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 } /// /// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei diff --git a/LehrerApp.Core/Services/LessonSchedulingService.cs b/LehrerApp.Core/Services/LessonSchedulingService.cs new file mode 100644 index 0000000..0e90202 --- /dev/null +++ b/LehrerApp.Core/Services/LessonSchedulingService.cs @@ -0,0 +1,99 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +/// Gemeinsame Terminlogik für Verschieben und Trennen von Stunden. +public sealed class LessonSchedulingService(ILessonRepository lessons) +{ + public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing) + { + EnsureTargetIsFree(lesson, newDate, newPeriod); + var oldDate = lesson.Date; + var delta = newDate.DayNumber - oldDate.DayNumber; + + if (shiftFollowing && delta != 0) + { + foreach (var other in lessons.GetByUnit(lesson.UnitId)) + { + if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate) + continue; + other.Date = other.Date.AddDays(delta); + lessons.Save(other); + } + } + + lesson.Date = newDate; + if (newPeriod.HasValue) lesson.LessonNumber = newPeriod; + lessons.Save(lesson); + } + + public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate, + int newPeriod, TimeOnly? newStartTime) + { + if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes) + throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus."); + EnsureTargetIsFree(source, newDate, newPeriod); + + var first = new List(); + var second = new List(); + var elapsed = 0; + foreach (var phase in source.Phases) + { + var remainingInFirst = splitAfterMinutes - elapsed; + if (remainingInFirst <= 0) + second.Add(Clone(phase, phase.DurationMinutes)); + else if (phase.DurationMinutes <= remainingInFirst) + first.Add(Clone(phase, phase.DurationMinutes)); + else + { + first.Add(Clone(phase, remainingInFirst)); + second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst)); + } + elapsed += phase.DurationMinutes; + } + + source.Phases = first; + var continuation = new Lesson + { + UnitId = source.UnitId, + GroupId = source.GroupId, + Date = newDate, + LessonNumber = newPeriod, + StartTime = newStartTime, + Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} – Fortsetzung", + Phases = second, + Homework = source.Homework, + HomeworkChecked = source.HomeworkChecked, + HomeworkCheckDismissed = source.HomeworkCheckDismissed, + Reflection = source.Reflection, + Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status, + }; + source.Homework = null; + source.HomeworkChecked = false; + source.HomeworkCheckDismissed = false; + source.Reflection = null; + lessons.Save(source); + lessons.Save(continuation); + return continuation; + } + + private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period) + { + if (period is null) return; + var occupied = lessons.GetByGroupAndDate(source.GroupId, date) + .Any(l => l.Id != source.Id && l.LessonNumber == period); + if (occupied) + throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung."); + } + + private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new() + { + Name = source.Name, + DurationMinutes = duration, + Activity = source.Activity, + Material = source.Material, + Shorthand = source.Shorthand, + AlternativePathId = source.AlternativePathId, + }; +} diff --git a/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs b/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs new file mode 100644 index 0000000..17d8f81 --- /dev/null +++ b/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs @@ -0,0 +1,90 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Planning; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class LessonSchedulingServiceTests +{ + [Fact] + public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht() + { + var groupId = Guid.NewGuid(); + var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned }; + var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active }; + var repo = new FakeUnits(); + repo.Add(planned); repo.Add(active); + var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3); + + Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id); + vm.NewUnitTitle = "Neue Reihe"; + vm.SaveCommand.Execute(null); + + Assert.True(vm.ResultIsNew); + Assert.Equal("Neue Reihe", vm.Result?.Title); + Assert.Null(repo.GetById(vm.Result!.Id)); + } + + [Fact] + public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach() + { + var unitId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 }; + var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft }; + var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted }; + var repo = new FakeLessons(); + repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted); + + new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true); + + Assert.Equal(new DateOnly(2026, 9, 8), moved.Date); + Assert.Equal(5, moved.LessonNumber); + Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date); + Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date); + } + + [Fact] + public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase() + { + var source = new Lesson + { + UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1), + LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2", + Phases = + [ + new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 }, + new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 }, + new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 }, + ], + }; + var repo = new FakeLessons(); + repo.Add(source); + + var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45, + new(2026, 9, 3), 6, new TimeOnly(12, 15)); + + Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes)); + Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes)); + Assert.Equal("Fotosynthese – Fortsetzung", continuation.Topic); + Assert.Null(source.Homework); + Assert.Equal("Aufgabe 2", continuation.Homework); + Assert.Equal(6, continuation.LessonNumber); + } + + [Fact] + public void Move_LehntBelegtenZielterminAb() + { + var groupId = Guid.NewGuid(); + var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 }; + var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 }; + var repo = new FakeLessons(); + repo.Add(source); repo.Add(occupied); + + var error = Assert.Throws(() => + new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false)); + + Assert.Contains("bereits eine Planung", error.Message); + } +} diff --git a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs index b71b22e..f1d3b69 100644 --- a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs @@ -277,7 +277,23 @@ public sealed class TimetableViewModelTests Assert.Equal(group.Id, navigatedTo); Assert.False(viewerOpened); - Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel); + Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel); + } + + [Fact] + public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + TimetableLessonRequest? requested = null; + vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; }; + + await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]); + + Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested); } // ── Unterrichtsmodus (14.x) ──────────────────────────────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs index 911b63d..75ac1c2 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs @@ -138,7 +138,7 @@ public partial class GroupOverviewViewModel : ObservableObject private void LoadNextLesson(DateOnly today) { var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays)) - .Where(l => l.Status == LessonStatus.Planned) + .Where(l => l.Status != LessonStatus.Conducted) .OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0) .FirstOrDefault(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 2ce7ec7..f4365d1 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -13,7 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups; // ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ─── -public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing); +public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing, int? NewPeriod = null, + bool SplitDoubleLesson = false); public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate); public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting) @@ -78,6 +79,7 @@ public partial class PlanningTabViewModel : ObservableObject public Func>? OnGenerateLessonSeries { get; set; } public Func>? OnAiAssist { get; set; } public Action? OnNotify { get; set; } + public Action? OnError { get; set; } public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, ISubjectRepository subjects, @@ -333,7 +335,8 @@ public partial class PlanningTabViewModel : ObservableObject var lesson = SelectedLesson.Model; var target = await OnPickMoveTarget(lesson); if (target is null) return; - MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing); + try { MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing, target.NewPeriod); } + catch (InvalidOperationException ex) { OnError?.Invoke(ex.Message); return; } LoadUnits(); } @@ -341,33 +344,19 @@ public partial class PlanningTabViewModel : ObservableObject /// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der /// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden /// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert. - private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing) + private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing, int? newPeriod = null) { - var oldDate = moved.Date; - var delta = newDate.DayNumber - oldDate.DayNumber; - - if (shiftFollowing && delta != 0) - { - foreach (var other in _lessons.GetByUnit(moved.UnitId)) - { - if (other.Id == moved.Id) continue; - if (other.Status != LessonStatus.Planned) continue; - if (other.Date <= oldDate) continue; - other.Date = other.Date.AddDays(delta); - _lessons.Save(other); - } - } - - moved.Date = newDate; - _lessons.Save(moved); + new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing); } [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private void AdvanceLessonStatus() { - if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return; + if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return; var lesson = SelectedLesson.Model; - lesson.Status = LessonStatus.Conducted; + lesson.Status = lesson.Status == LessonStatus.Ready + ? LessonStatus.Conducted + : LessonStatus.Ready; _lessons.Save(lesson); LoadUnits(); } @@ -479,8 +468,14 @@ public class LessonSummary Topic = l.Topic; StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–"; Status = l.Status; - StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant"; - StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E"; + StatusLabel = LessonStatusDisplay.ToName(l.Status); + StatusColorHex = l.Status switch + { + LessonStatus.Draft => "#78909C", + LessonStatus.Ready => "#1976D2", + LessonStatus.Conducted => "#43A047", + _ => "#9E9E9E", + }; PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen"; var totalMinutes = l.Phases.Sum(p => p.DurationMinutes); TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min."; @@ -515,12 +510,23 @@ public static class UnitStatusDisplay public static class LessonStatusDisplay { - public static string[] Options { get; } = ["Geplant", "Durchgeführt"]; + public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"]; - public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant"; + public static string ToName(LessonStatus s) => s switch + { + LessonStatus.Draft => "Entwurf", + LessonStatus.Ready => "Bereit", + LessonStatus.Conducted => "Durchgeführt", + _ => "Geplant", + }; - public static LessonStatus FromName(string? name) => - name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned; + public static LessonStatus FromName(string? name) => name switch + { + "Entwurf" => LessonStatus.Draft, + "Bereit" => LessonStatus.Ready, + "Durchgeführt" => LessonStatus.Conducted, + _ => LessonStatus.Planned, + }; } // ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ───────────────────── @@ -722,7 +728,8 @@ public partial class LessonDialogViewModel : ObservableObject IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage, Guid unitId, Guid groupId, string groupName, string subjectName, - List materialSuggestions, List shorthandHistorySuggestions, Lesson? editingLesson) + List materialSuggestions, List shorthandHistorySuggestions, Lesson? editingLesson, + DateOnly? suggestedDate = null, int? suggestedPeriod = null) { _lessons = lessons; _alternativePaths = alternativePaths; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; @@ -760,9 +767,12 @@ public partial class LessonDialogViewModel : ObservableObject } else { - var (date, lessonNumber) = SuggestNextLesson(); + var (date, lessonNumber) = suggestedDate.HasValue + ? (suggestedDate.Value, suggestedPeriod) + : SuggestNextLesson(); DateText = date.ToString("dd.MM.yyyy"); LessonNumber = lessonNumber; + StatusName = LessonStatusDisplay.ToName(LessonStatus.Draft); } RecomputeTimes(); } @@ -1145,28 +1155,45 @@ public partial class AlternativePathDialogViewModel : ObservableObject public partial class MoveLessonDialogViewModel : ObservableObject { [ObservableProperty] private string _newDateText; + [ObservableProperty] private int? _newPeriod; [ObservableProperty] private bool _shiftFollowingPlanned = true; + [ObservableProperty] private bool _splitDoubleLesson; [ObservableProperty] private string _newDateTextError = ""; + [ObservableProperty] private string _newPeriodError = ""; public string CurrentDateDisplay { get; } + public string CurrentPeriodDisplay { get; } + public bool CanSplitDoubleLesson { get; } public MoveLessonTarget? Result { get; private set; } - public MoveLessonDialogViewModel(DateOnly currentDate) + public MoveLessonDialogViewModel(DateOnly currentDate, int? currentPeriod = null, + bool canSplitDoubleLesson = false, bool splitByDefault = false) { CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy"); + CurrentPeriodDisplay = currentPeriod is null ? "nicht festgelegt" : $"{currentPeriod}. Stunde"; _newDateText = currentDate.ToString("dd.MM.yyyy"); + _newPeriod = currentPeriod; + CanSplitDoubleLesson = canSplitDoubleLesson; + _splitDoubleLesson = canSplitDoubleLesson && splitByDefault; } [RelayCommand] private void Save() { NewDateTextError = ""; + NewPeriodError = ""; if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) { NewDateTextError = "Format TT.MM.JJJJ."; return; } - Result = new MoveLessonTarget(date, ShiftFollowingPlanned); + if (NewPeriod is < 1 or > 20) + { + NewPeriodError = "Bitte eine Stundennummer zwischen 1 und 20 wählen."; + return; + } + Result = new MoveLessonTarget(date, ShiftFollowingPlanned, NewPeriod, + CanSplitDoubleLesson && SplitDoubleLesson); } } diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableLessonWorkflowViewModels.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableLessonWorkflowViewModels.cs new file mode 100644 index 0000000..aa4df1e --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableLessonWorkflowViewModels.cs @@ -0,0 +1,83 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Planning; + +public sealed class TimetableUnitOption(Unit unit) +{ + public Unit Model { get; } = unit; + public string Label { get; } = unit.Title; + public string Detail { get; } = unit.Status switch + { + UnitStatus.Active => "Laufende Einheit", + UnitStatus.Completed => "Abgeschlossene Einheit", + _ => "Geplante Einheit", + }; +} + +/// Ordnet eine direkt aus dem Stundenplan angelegte Stunde einer Einheit zu. +public partial class TimetableUnitPickerViewModel : ObservableObject +{ + private readonly IUnitRepository _units; + private readonly Guid _groupId; + private readonly DateOnly _date; + + public string ContextLabel { get; } + public ObservableCollection Units { get; } = []; + [ObservableProperty] private TimetableUnitOption? _selectedUnit; + [ObservableProperty] private string _newUnitTitle = ""; + [ObservableProperty] private string _error = ""; + public Unit? Result { get; private set; } + public bool ResultIsNew { get; private set; } + public bool HasUnits => Units.Count > 0; + + public TimetableUnitPickerViewModel(IUnitRepository units, Guid groupId, string groupName, + DateOnly date, int period) + { + _units = units; + _groupId = groupId; + _date = date; + ContextLabel = $"{groupName} · {date:dd.MM.yyyy} · {period}. Stunde"; + + foreach (var unit in units.GetByGroup(groupId) + .OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2) + .ThenByDescending(u => u.StartDate) + .ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase)) + Units.Add(new TimetableUnitOption(unit)); + SelectedUnit = Units.FirstOrDefault(); + } + + [RelayCommand] + private void Save() + { + Error = ""; + if (!string.IsNullOrWhiteSpace(NewUnitTitle)) + { + Result = new Unit + { + GroupId = _groupId, + Title = NewUnitTitle.Trim(), + StartDate = _date, + Status = UnitStatus.Active, + }; + // Erst speichern, wenn auch der anschließende Stunden-Dialog bestätigt wurde. So + // hinterlässt ein Abbruch keine leere Einheit. + ResultIsNew = true; + return; + } + + if (SelectedUnit is null) + { + Error = "Bitte eine Einheit auswählen oder eine neue benennen."; + return; + } + Result = SelectedUnit.Model; + ResultIsNew = false; + } +} + +public sealed record TimetableLessonRequest(Guid GroupId, DateOnly Date, int PeriodNumber); +public sealed record TimetableLessonMoveRequest(Lesson Lesson, int SelectedPeriod); diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index e6c30a3..baab819 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -5,6 +5,7 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Settings; +using LehrerApp.Desktop.ViewModels.Groups; using System.Collections.ObjectModel; namespace LehrerApp.Desktop.ViewModels.Planning; @@ -108,6 +109,8 @@ public partial class TimetableViewModel : ObservableObject public Action? OnNavigateToSettings { get; set; } public Func? OnOpenLessonViewer { get; set; } public Func? OnOpenTeachingMode { get; set; } + public Func? OnCreateLesson { get; set; } + public Func? OnMoveLesson { get; set; } /// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die /// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne @@ -301,9 +304,9 @@ public partial class TimetableViewModel : ObservableObject var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber); if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; } - var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault(); + var lesson = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber); var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today); - items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name, + items.Add(new TodayLessonItem(slot.GroupId, today, slot.PeriodNumber, group.Name, slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title, HasUnhandledHomework(slot.GroupId, today), lesson)); } @@ -353,15 +356,15 @@ public partial class TimetableViewModel : ObservableObject } /// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2) - /// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson (Slot laut Stundenplan belegt, - /// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation - /// zum Planung-Tab der Gruppe: eine neue Lesson direkt von hier aus anzulegen bräuchte eine - /// Antwort auf "welcher Unit wird sie zugeordnet", die bewusst noch offen ist (siehe TODO 4.5.2). + /// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage; + /// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit. [RelayCommand] private async Task OpenTodayLesson(TodayLessonItem? item) { if (item is null || item.GroupId == Guid.Empty) return; if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson); + else if (OnCreateLesson is not null) + await OnCreateLesson(new TimetableLessonRequest(item.GroupId, item.Date, item.PeriodNumber)); else OnNavigateToGroup?.Invoke(item.GroupId); } @@ -380,7 +383,7 @@ public partial class TimetableViewModel : ObservableObject /// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie /// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung /// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet - /// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik. + /// daneben die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an. [RelayCommand] private async Task OpenWeekCell(WeekCellItem? item) { @@ -391,6 +394,8 @@ public partial class TimetableViewModel : ObservableObject await OnOpenTeachingMode(lesson); else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson); } + else if (item.Date is { } date && OnCreateLesson is not null) + await OnCreateLesson(new TimetableLessonRequest(item.GroupId, date, item.PeriodNumber)); else OnNavigateToGroup?.Invoke(item.GroupId); } @@ -399,16 +404,20 @@ public partial class TimetableViewModel : ObservableObject /// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen /// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes /// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer. - /// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort - /// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot). + /// Nimmt das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die + /// Direktanlage einer noch nicht existierenden Stunde. private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10); private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber) { var nowSnapshot = Clock(); if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false; if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false; - var now = TimeOnly.FromDateTime(nowSnapshot); - return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance); + // DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen + // Tagesrand und macht aus z.B. 00:05 ± 10 Minuten ein umgekehrtes Vergleichsfenster. + var start = lessonDate.ToDateTime(times.Start).Subtract(TeachingTimeTolerance); + var end = lessonDate.ToDateTime(times.End).Add(TeachingTimeTolerance); + if (end < start) end = end.AddDays(1); // nur für ein ggf. über Mitternacht laufendes Raster + return nowSnapshot >= start && nowSnapshot <= end; } [RelayCommand] @@ -487,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject continue; } - var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault(); + var lesson = FindLessonForSlot(slot.GroupId, date, period); var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date); var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates); var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? ""); var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates); var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates); - WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, + WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, date, subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "", group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "", colorHex, holidayBadge, hasExam, isLastBeforeExam, @@ -560,6 +569,31 @@ public partial class TimetableViewModel : ObservableObject p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true || p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true; + /// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite + /// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar + /// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an + /// der Zielperiode verankerte Lesson hat immer Vorrang. + private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period) + { + var lessons = _lessons.GetByGroupAndDate(groupId, date); + var exact = lessons.FirstOrDefault(l => l.LessonNumber == period); + if (exact is not null) return exact; + // Historische/manuell angelegte Einträge hatten häufig keine Stundennummer. Solange es + // davon nur einen an diesem Tag gibt, bleibt das frühere Verhalten erhalten und er wird + // dem vorhandenen Gruppen-Slot zugeordnet. + var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList(); + if (withoutPeriod.Count == 1) return withoutPeriod[0]; + + var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1) + .OrderByDescending(l => l.UpdatedAt).FirstOrDefault(); + if (previous?.LessonNumber is not int anchor) return null; + var isConsecutiveSlot = _slots.GetByGroup(groupId) + .Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period); + var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor); + return isConsecutiveSlot && firstPeriodMinutes > 0 && + previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null; + } + /// 4.5.4: Hat die letzte vor liegende Lesson dieser Gruppe eine /// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut /// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald @@ -824,7 +858,9 @@ public partial class WeekCellItem : ObservableObject /// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2). public Lesson? Lesson { get; private init; } public bool HasLesson => Lesson is not null; - public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe"; + public bool HasNoLesson => Lesson is null; + public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status); + public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen"; [ObservableProperty] private string _weatherSymbol = ""; [ObservableProperty] private string _weatherTooltip = ""; [ObservableProperty] private bool _hasWeatherWarning; @@ -879,18 +915,28 @@ public partial class WeekCellItem : ObservableObject IsSubstitutionSupervision = isSubstitution, }; - public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel, + public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, DateOnly date, string subjectLabel, string groupName, string room, string topic, string colorHex, string holidayBadge, bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday, bool hasUnhandledHomework = false, Lesson? lesson = null) => new() { - Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, + Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, Date = date, SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic, ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam, IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId, IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson, }; + // Kompatible Überladung für isolierte ViewModel-Tests und ältere Aufrufer ohne konkreten + // Wochenbezug. Produktiv wird die datierte Variante verwendet. + public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel, + string groupName, string room, string topic, string colorHex, string holidayBadge, + bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday, + bool hasUnhandledHomework = false, Lesson? lesson = null) => ForSlot(day, period, isToday, + DateOnly.FromDateTime(DateTime.Today), subjectLabel, groupName, room, topic, colorHex, + holidayBadge, hasExam, isLastBeforeExam, hasExperiment, groupId, isHoliday, + hasUnhandledHomework, lesson); + public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new() { Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true, @@ -937,12 +983,13 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title) public string Title { get; } = title; } -public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning } +public enum TimetableLessonDestination { TeachingMode, Viewer, Create, Move, SeatingPlan, Planning } public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label); public class TodayLessonItem { public Guid GroupId { get; private init; } + public DateOnly Date { get; private init; } public int PeriodNumber { get; private init; } public string GroupName { get; private init; } = ""; public string Room { get; private init; } = ""; @@ -960,23 +1007,24 @@ public class TodayLessonItem /// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x). public Lesson? Lesson { get; private init; } public bool HasLesson => Lesson is not null; - public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe"; + public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status); + public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen"; - public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room, + public TodayLessonItem(Guid groupId, DateOnly date, int periodNumber, string groupName, string room, string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false, Lesson? lesson = null) { - GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room; + GroupId = groupId; Date = date; PeriodNumber = periodNumber; GroupName = groupName; Room = room; ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle; HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson; } public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new( - entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null) + entry.GroupId ?? Guid.Empty, entry.Date, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null) { IsSubstitution = true }; public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new( - groupId, periodNumber, groupName, "", "#757575", + groupId, entry.Date, periodNumber, groupName, "", "#757575", string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null) { IsCancelled = true }; } diff --git a/LehrerApp.Desktop/Views/Groups/MoveLessonDialog.axaml b/LehrerApp.Desktop/Views/Groups/MoveLessonDialog.axaml index 76e96d6..13ac99f 100644 --- a/LehrerApp.Desktop/Views/Groups/MoveLessonDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/MoveLessonDialog.axaml @@ -4,7 +4,7 @@ x:Class="LehrerApp.Desktop.Views.Groups.MoveLessonDialog" x:DataType="vm:MoveLessonDialogViewModel" Title="Stunde verschieben" - Width="400" Height="260" MinWidth="360" MinHeight="240" + Width="440" Height="430" MinWidth="400" MinHeight="400" CanResize="False" WindowStartupLocation="CenterOwner"> @@ -13,6 +13,8 @@ + + @@ -22,6 +24,24 @@ IsVisible="{Binding NewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml index 0064e4c..5a4a8f0 100644 --- a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml @@ -91,7 +91,8 @@ ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>