From d6dee72acc551c0352b4da1acfbcb9c1673aca07 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sat, 15 Aug 2026 21:59:07 +0200 Subject: [PATCH] Unterrichtsplanung: Serienerzeugung, Stundenraster, Aufsichten & Vertretung (Kapitel 4.2/4.3 Nachtrag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stunden serienweise aus dem Stundenplan erzeugen (4.2.5); Stundenraster (Uhrzeiten je Stunde) in den Einstellungen mit Zeitbedarf-Rückmeldung im Verlaufsplan-Editor; wiederkehrende Pausenaufsicht; neuer "Vertretung eintragen"-Dialog für einmalige Vertretungsaufsicht, Vertretungsstunde, Sondereinsätze (Ausflüge, Berufsmessen) und schlichten Stundenausfall. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 13 + LehrerApp.Core/Models/Planning.cs | 59 +++ .../Services/PeriodScheduleService.cs | 62 ++++ LehrerApp.Data.Tests/RepositoryTests.cs | 57 +++ LehrerApp.Data/LiteDbContext.cs | 5 + .../Repositories/AllRepositories.cs | 24 ++ LehrerApp.Desktop.Tests/Fakes.cs | 26 ++ ...enerateLessonSeriesDialogViewModelTests.cs | 142 ++++++++ .../LessonDialogViewModelTests.cs | 149 +++++++- .../PlanningTabViewModelTests.cs | 23 ++ .../SettingsViewModelTests.cs | 121 ++++++- .../SubstitutionEntryDialogViewModelTests.cs | 323 +++++++++++++++++ .../TimetableViewModelTests.cs | 336 +++++++++++++++++- LehrerApp.Desktop/AppBootstrapper.cs | 3 + .../ViewModels/Groups/PlanningViewModels.cs | 183 +++++++++- .../SubstitutionEntryDialogViewModel.cs | 176 +++++++++ .../ViewModels/Planning/TimetableViewModel.cs | 289 +++++++++++++-- .../ViewModels/Settings/SettingsViewModel.cs | 143 +++++++- .../Groups/GenerateLessonSeriesDialog.axaml | 36 ++ .../GenerateLessonSeriesDialog.axaml.cs | 21 ++ .../Views/Groups/LessonDialog.axaml | 8 +- .../Views/Groups/PlanningTabView.axaml | 2 + .../Views/Groups/PlanningTabView.axaml.cs | 25 ++ .../Planning/SubstitutionEntryDialog.axaml | 144 ++++++++ .../Planning/SubstitutionEntryDialog.axaml.cs | 21 ++ .../Views/Planning/TimetableView.axaml | 76 +++- .../Views/Planning/TimetableView.axaml.cs | 21 +- .../Views/Settings/SettingsView.axaml | 94 +++++ LehrerApp.Tests/PeriodScheduleServiceTests.cs | 56 +++ TODO.md | 153 +++++++- 30 files changed, 2743 insertions(+), 48 deletions(-) create mode 100644 LehrerApp.Core/Services/PeriodScheduleService.cs create mode 100644 LehrerApp.Desktop.Tests/GenerateLessonSeriesDialogViewModelTests.cs create mode 100644 LehrerApp.Desktop.Tests/SubstitutionEntryDialogViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Planning/SubstitutionEntryDialogViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Planning/SubstitutionEntryDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Planning/SubstitutionEntryDialog.axaml.cs create mode 100644 LehrerApp.Tests/PeriodScheduleServiceTests.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index b305ad2..e225802 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -98,6 +98,19 @@ public interface ISchoolHolidayRepository void Save(SchoolHoliday holiday); void Delete(Guid id); } +public interface ISupervisionDutyRepository +{ + List GetAll(); + void Save(SupervisionDuty duty); + void Delete(Guid id); +} +public interface ISubstitutionEntryRepository +{ + List GetAll(); + List GetByDate(DateOnly date); + void Save(SubstitutionEntry entry); + void Delete(Guid id); +} public interface IDocumentationRepository { List GetByStudent(Guid studentId); diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index ba8f829..4401fdf 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -143,6 +143,65 @@ public enum GermanState BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH } +/// +/// Wiederkehrende Pausenaufsicht (kein Bezug zu einer Lerngruppe, anders als +/// ). = 0 bedeutet "vor der 1. Stunde" +/// (Frühaufsicht), sonst "in der Pause nach dieser Stunde". Pro Wochentag/Pause höchstens eine +/// Aufsicht (ein Lehrer kann nicht an zwei Orten gleichzeitig Aufsicht führen). +/// +public class SupervisionDuty +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public DayOfWeek Weekday { get; set; } + public int AfterPeriod { get; set; } + public string Location { get; set; } = ""; +} + +public enum SubstitutionKind { Supervision, Lesson, SpecialAssignment, Cancelled } + +/// +/// Einmalige Ausnahme vom regulären Plan an einem konkreten Datum: eine Vertretungsaufsicht +/// (zusätzlich zur oder anstelle einer regulären ), eine +/// Vertretungsstunde in einer eigenen oder fremden Lerngruppe, ein Sondereinsatz (Ausflug, +/// Berufsmesse, Exkursion, ...), der einen Teil des Tages oder den ganzen Tag blockiert, oder ein +/// schlichter Stundenausfall ohne Ersatz (z.B. weil die betroffene Gruppe selbst auf Klassenfahrt +/// ist). Bewusst getrennt von : die meisten dieser Einträge sind nicht Teil der +/// durchgeplanten Einheiten-Reihenfolge (siehe TODO.md, Nachtrag zu 4.3) — nur bei +/// kann beim Anlegen ausdrücklich "als Stunde in der Einheit +/// übernehmen" gewählt werden, was zusätzlich eine echte erzeugt; dieser +/// Eintrag hier bleibt trotzdem bestehen, da er (unabhängig von der Einheiten-Fortschrittsanzeige) +/// das, was tatsächlich im Plan an diesem Tag stattfand, festhält. +/// +public class SubstitutionEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public DateOnly Date { get; set; } + public SubstitutionKind Kind { get; set; } + /// Bei und + /// gesetzt — bei Cancelled wird Gruppe/Fach zur Anzeige aus dem an dieser Stelle regulär + /// eingetragenen abgeleitet, nicht hier gespeichert. + public int? PeriodNumber { get; set; } + /// Nur bei gesetzt — wie bei + /// , 0 = vor der 1. Stunde. + public int? AfterPeriod { get; set; } + /// Nur bei und nicht : + /// der belegte Stundenbereich (inklusive). + public int? FromPeriod { get; set; } + public int? ToPeriod { get; set; } + /// Nur bei : blockiert den ganzen Tag statt + /// nur . + public bool IsAllDay { get; set; } + /// Gesetzt, wenn es sich um eine eigene Lerngruppe handelt — bei fremden/unbekannten Gruppen + /// oder Sondereinsätzen ohne Gruppenbezug (z.B. Berufsmesse) bleibt es null und nur + /// beschreibt ggf., um welche Gruppe es ging. + public Guid? GroupId { get; set; } + public string GroupLabel { get; set; } = ""; + /// Thema (bei Lesson), Grund/Ort (bei Supervision, z.B. "Vertretung für Hr. Müller") bzw. + /// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse"). + public string Description { get; set; } = ""; + public string? Notes { get; set; } +} + /// /// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr). /// ist das zuletzt berechnete Ergebnis; diff --git a/LehrerApp.Core/Services/PeriodScheduleService.cs b/LehrerApp.Core/Services/PeriodScheduleService.cs new file mode 100644 index 0000000..d9b76d2 --- /dev/null +++ b/LehrerApp.Core/Services/PeriodScheduleService.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace LehrerApp.Core.Services; + +public class PeriodTimeEntry +{ + public int PeriodNumber { get; set; } + public TimeOnly Start { get; set; } + public TimeOnly End { get; set; } +} + +internal class PeriodScheduleConfig +{ + public List Periods { get; set; } = []; +} + +/// +/// Uhrzeiten der Einzelstunden (Stundenraster) — Grundlage für die Zeitbedarf-Rückmeldung im +/// Verlaufsplan-Editor (4.2.2 Nachtrag). Nicht jede Schule/Stunde muss konfiguriert sein: fehlt +/// eine Stunde, liefert 0 statt eines Standardwerts — die +/// Zeitbedarf-Anzeige blendet sich dann einfach aus, statt eine erfundene Dauer vorzutäuschen. +/// +public class PeriodScheduleService +{ + private readonly string _configPath; + private PeriodScheduleConfig _config; + + public IReadOnlyList Periods => _config.Periods; + + public PeriodScheduleService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "periodschedule.json"); + _config = Load(); + } + + public void SetPeriods(List periods) + { + _config.Periods = periods; + File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); + } + + public (TimeOnly Start, TimeOnly End)? GetTimes(int periodNumber) + { + var entry = _config.Periods.FirstOrDefault(p => p.PeriodNumber == periodNumber); + return entry is null ? null : (entry.Start, entry.End); + } + + public int GetDurationMinutes(int periodNumber) => + GetTimes(periodNumber) is { } t ? (int)(t.End - t.Start).TotalMinutes : 0; + + private PeriodScheduleConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new PeriodScheduleConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwert */ } + return new PeriodScheduleConfig(); + } +} diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 036d923..48f046f 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -588,4 +588,61 @@ public sealed class RepositoryTests Assert.Equal("Osterferien", result[0].Name); Assert.Equal("Sommerferien", result[1].Name); } + + // ── SupervisionDutyRepository ───────────────────────────────────────────── + + [Fact] + public void SupervisionDutyRepository_Save_LehntDoppelbelegungDerselbenPauseAb() + { + using var db = NewInMemoryContext(); + var repo = new SupervisionDutyRepository(db); + repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); + + Assert.Throws(() => + repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Bibliothek" })); + } + + [Fact] + public void SupervisionDutyRepository_Save_AktualisierenDerselbenAufsichtIstErlaubt() + { + using var db = NewInMemoryContext(); + var repo = new SupervisionDutyRepository(db); + var duty = new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }; + repo.Save(duty); + + duty.Location = "Bibliothek"; + repo.Save(duty); + + Assert.Equal("Bibliothek", repo.GetAll().Single().Location); + } + + [Fact] + public void SupervisionDutyRepository_GetAll_SortiertNachWochentagUndPause() + { + using var db = NewInMemoryContext(); + var repo = new SupervisionDutyRepository(db); + repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Tuesday, AfterPeriod = 1, Location = "A" }); + repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 4, Location = "B" }); + repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "C" }); + + var result = repo.GetAll(); + + Assert.Equal(["C", "B", "A"], result.Select(d => d.Location)); + } + + // ── SubstitutionEntryRepository ─────────────────────────────────────────── + + [Fact] + public void SubstitutionEntryRepository_GetByDate_FindetNurEintraegeDesTages() + { + using var db = NewInMemoryContext(); + var repo = new SubstitutionEntryRepository(db); + repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Lesson, PeriodNumber = 3, Description = "Vertretung 8a" }); + repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Supervision, AfterPeriod = 2, Description = "Vertretung Hr. Müller" }); + + var result = repo.GetByDate(new DateOnly(2026, 3, 12)); + + Assert.Single(result); + Assert.Equal("Vertretung 8a", result[0].Description); + } } diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 1f391db..0539174 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -59,6 +59,8 @@ public class LiteDbContext : IDisposable public ILiteCollection AlternativeLessonPaths => _db.GetCollection("alternative_lesson_paths"); public ILiteCollection TimetableSlots => _db.GetCollection("timetable_slots"); public ILiteCollection SchoolHolidays => _db.GetCollection("school_holidays"); + public ILiteCollection SupervisionDuties => _db.GetCollection("supervision_duties"); + public ILiteCollection SubstitutionEntries => _db.GetCollection("substitution_entries"); public void Checkpoint() => _db.Checkpoint(); @@ -374,6 +376,9 @@ public class LiteDbContext : IDisposable TimetableSlots.EnsureIndex(x => x.GroupId); TimetableSlots.EnsureIndex("ux_timetable_weekday_period", BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true); + SupervisionDuties.EnsureIndex("ux_supervision_weekday_period", + BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true); + SubstitutionEntries.EnsureIndex(x => x.Date); } public void Dispose() => _db.Dispose(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 2930bf1..2793f0f 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -410,6 +410,30 @@ public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepositor public void Delete(Guid id) => db.SchoolHolidays.Delete(id); } +public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository +{ + public List GetAll() => + db.SupervisionDuties.FindAll().OrderBy(d => d.Weekday).ThenBy(d => d.AfterPeriod).ToList(); + public void Save(SupervisionDuty duty) + { + var occupied = db.SupervisionDuties.FindAll() + .FirstOrDefault(d => d.Weekday == duty.Weekday && d.AfterPeriod == duty.AfterPeriod); + if (occupied is not null && occupied.Id != duty.Id) + throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen."); + db.SupervisionDuties.Upsert(duty); + } + public void Delete(Guid id) => db.SupervisionDuties.Delete(id); +} + +public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository +{ + public List GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList(); + public List GetByDate(DateOnly date) => + db.SubstitutionEntries.Find(e => e.Date == date).ToList(); + public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry); + public void Delete(Guid id) => db.SubstitutionEntries.Delete(id); +} + public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository { public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index a2b82b5..291d4bd 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -243,6 +243,32 @@ public class FakeSchoolHolidays : ISchoolHolidayRepository public void Delete(Guid id) => _all.RemoveAll(h => h.Id == id); } +public class FakeSupervisionDuties : ISupervisionDutyRepository +{ + private readonly List _all = []; + public void Add(SupervisionDuty d) => _all.Add(d); + public List GetAll() => _all.ToList(); + public void Save(SupervisionDuty duty) + { + var occupied = _all.FirstOrDefault(d => d.Weekday == duty.Weekday && d.AfterPeriod == duty.AfterPeriod); + if (occupied is not null && occupied.Id != duty.Id) + throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen."); + _all.RemoveAll(d => d.Id == duty.Id); + _all.Add(duty); + } + public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); +} + +public class FakeSubstitutionEntries : ISubstitutionEntryRepository +{ + private readonly List _all = []; + public void Add(SubstitutionEntry e) => _all.Add(e); + public List GetAll() => _all.ToList(); + public List GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList(); + public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } + public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); +} + public class FakeReportGrades : IReportGradeRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/GenerateLessonSeriesDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/GenerateLessonSeriesDialogViewModelTests.cs new file mode 100644 index 0000000..9c71427 --- /dev/null +++ b/LehrerApp.Desktop.Tests/GenerateLessonSeriesDialogViewModelTests.cs @@ -0,0 +1,142 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für die Serienerzeugung von Stunden aus dem Stundenplan (4.2.5). +public sealed class GenerateLessonSeriesDialogViewModelTests +{ + private static GenerateLessonSeriesDialogViewModel BuildVm(FakeTimetableSlots slots, FakeLessons lessons, + FakeSchoolHolidays holidays, Guid unitId, Guid groupId, DateOnly? from = null, DateOnly? to = null) + { + // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf. + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-lessonseries-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + + var vm = new GenerateLessonSeriesDialogViewModel(slots, lessons, holidays, + new PublicHolidayService(), new SchoolCalendarSettingsService(tempPath), unitId, groupId, null, null); + if (from is not null) vm.FromDateText = from.Value.ToString("dd.MM.yyyy"); + if (to is not null) vm.ToDateText = to.Value.ToString("dd.MM.yyyy"); + return vm; + } + + [Fact] + public void Save_ErzeugtStundeProVorkommenDesWochentagsImZeitraum() + { + var groupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var from = new DateOnly(2025, 9, 1); + var to = from.AddDays(13); // deckt zwei Vorkommen des Wochentags von "from" ab (Tag 0 und 7) + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 }); + var lessons = new FakeLessons(); + var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, to); + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal(2, vm.Result!.Created); + Assert.Equal(0, vm.Result.SkippedHoliday); + Assert.Equal(0, vm.Result.SkippedExisting); + Assert.All(lessons.GetByGroupAndRange(groupId, from, to), l => Assert.Equal(unitId, l.UnitId)); + } + + [Fact] + public void Save_UeberspringtTerminInSchulferien() + { + var groupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var from = new DateOnly(2025, 9, 1); + var to = from.AddDays(13); + var secondOccurrence = from.AddDays(7); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 }); + var holidays = new FakeSchoolHolidays(); + holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = secondOccurrence, EndDate = secondOccurrence.AddDays(3) }); + var vm = BuildVm(slots, new FakeLessons(), holidays, unitId, groupId, from, to); + + vm.SaveCommand.Execute(null); + + Assert.Equal(1, vm.Result!.Created); + Assert.Equal(1, vm.Result.SkippedHoliday); + } + + [Fact] + public void Save_UeberspringtBereitsVorhandeneStundeAmSelbenDatumUndPeriode() + { + var groupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var from = new DateOnly(2025, 9, 1); + var to = from.AddDays(13); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 }); + var lessons = new FakeLessons(); + lessons.Add(new Lesson { UnitId = Guid.NewGuid(), GroupId = groupId, Date = from, LessonNumber = 1, Topic = "Bestehend" }); + var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, to); + + vm.SaveCommand.Execute(null); + + Assert.Equal(1, vm.Result!.Created); + Assert.Equal(1, vm.Result.SkippedExisting); + Assert.Equal(2, lessons.GetByGroupAndRange(groupId, from, to).Count); // 1 alt + 1 neu + } + + [Fact] + public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer() + { + var groupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var from = new DateOnly(2025, 9, 1); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 }); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 4 }); + var lessons = new FakeLessons(); + var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from); + + vm.SaveCommand.Execute(null); + + Assert.Equal(2, vm.Result!.Created); + var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList(); + Assert.Equal([3, 4], created); + } + + [Fact] + public void Save_OhneStundenplanEintrag_SetztFehlerUndErzeugtNichts() + { + var groupId = Guid.NewGuid(); + var vm = BuildVm(new FakeTimetableSlots(), new FakeLessons(), new FakeSchoolHolidays(), Guid.NewGuid(), groupId, + new DateOnly(2025, 9, 1), new DateOnly(2025, 9, 14)); + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.DateError); + } + + [Fact] + public void Save_UngueltigesDatumsformat_SetztFehler() + { + var vm = BuildVm(new FakeTimetableSlots(), new FakeLessons(), new FakeSchoolHolidays(), Guid.NewGuid(), Guid.NewGuid()); + vm.FromDateText = "keinDatum"; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.DateError); + } + + [Fact] + public void Save_EndeVorBeginn_SetztFehler() + { + var vm = BuildVm(new FakeTimetableSlots(), new FakeLessons(), new FakeSchoolHolidays(), Guid.NewGuid(), Guid.NewGuid(), + new DateOnly(2025, 9, 10), new DateOnly(2025, 9, 1)); + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.DateError); + } +} diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs index e749e6b..7526680 100644 --- a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Models; +using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels.Groups; using Xunit; @@ -6,8 +7,18 @@ namespace LehrerApp.Desktop.Tests; public sealed class LessonDialogViewModelTests { - private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null) => + private static PeriodScheduleService NewPeriodSchedule() + { + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-lessondialogvm-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new PeriodScheduleService(tempPath); + } + + private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null, + FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) => new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), + slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), unitId, groupId, [], [], editing); [Fact] @@ -71,6 +82,7 @@ public sealed class LessonDialogViewModelTests { var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]); var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]), + new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions); @@ -133,7 +145,7 @@ public sealed class LessonDialogViewModelTests var groupId = Guid.NewGuid(); var lessons = new FakeLessons(); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), - unitId, groupId, [], [], null) + new FakeTimetableSlots(), NewPeriodSchedule(), unitId, groupId, [], [], null) { Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45", }; @@ -230,9 +242,140 @@ public sealed class LessonDialogViewModelTests }; var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths, - Guid.NewGuid(), Guid.NewGuid(), [], [], editing); + new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), [], [], editing); Assert.True(vm.Phases[0].HasAlternativePath); Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName); } + + // ── Zeitbedarf-Rückmeldung (4.2.2 Nachtrag: Stundenraster) ─────────────────── + + [Fact] + public void RecomputeTimeBudget_EinzelneStunde_ZeigtVerfuegbareZeitAusStundenraster() + { + var groupId = Guid.NewGuid(); + var date = new DateOnly(2025, 9, 1); + var periodSchedule = NewPeriodSchedule(); + periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) }]); + var vm = BuildVm(Guid.NewGuid(), groupId, periodSchedule: periodSchedule); + vm.DateText = date.ToString("dd.MM.yyyy"); + vm.LessonNumber = 3; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 40; + + Assert.True(vm.HasTimeBudgetInfo); + Assert.Contains("40 von 45 Minuten geplant", vm.TimeBudgetLabel); + } + + [Fact] + public void RecomputeTimeBudget_Doppelstunde_AddiertBeidePerioden() + { + var groupId = Guid.NewGuid(); + var date = new DateOnly(2025, 9, 1); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 3 }); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 4 }); + var periodSchedule = NewPeriodSchedule(); + periodSchedule.SetPeriods([ + new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) }, + new PeriodTimeEntry { PeriodNumber = 4, Start = new TimeOnly(10, 30), End = new TimeOnly(11, 15) }, + ]); + var vm = BuildVm(Guid.NewGuid(), groupId, slots: slots, periodSchedule: periodSchedule); + vm.DateText = date.ToString("dd.MM.yyyy"); + vm.LessonNumber = 3; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 84; + + Assert.True(vm.HasTimeBudgetInfo); + Assert.Contains("84 von 90 Minuten geplant", vm.TimeBudgetLabel); + } + + [Fact] + public void RecomputeTimeBudget_FolgeperiodeGehoertAndererGruppe_WirdNichtMitgezaehlt() + { + var groupId = Guid.NewGuid(); + var otherGroupId = Guid.NewGuid(); + var date = new DateOnly(2025, 9, 1); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 3 }); + slots.Add(new TimetableSlot { GroupId = otherGroupId, Weekday = date.DayOfWeek, PeriodNumber = 4 }); + var periodSchedule = NewPeriodSchedule(); + periodSchedule.SetPeriods([ + new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) }, + new PeriodTimeEntry { PeriodNumber = 4, Start = new TimeOnly(10, 30), End = new TimeOnly(11, 15) }, + ]); + var vm = BuildVm(Guid.NewGuid(), groupId, slots: slots, periodSchedule: periodSchedule); + vm.DateText = date.ToString("dd.MM.yyyy"); + vm.LessonNumber = 3; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 40; + + Assert.Contains("40 von 45 Minuten geplant", vm.TimeBudgetLabel); + } + + [Fact] + public void RecomputeTimeBudget_FarbeGruenImZielbereich() + { + var date = new DateOnly(2025, 9, 1); + var periodSchedule = NewPeriodSchedule(); + periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); + vm.DateText = date.ToString("dd.MM.yyyy"); + vm.LessonNumber = 1; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 42; // 42/45 ≈ 93 % — Zielbereich + + Assert.Equal("#43A047", vm.TimeBudgetColorHex); + } + + [Fact] + public void RecomputeTimeBudget_FarbeDunkelrotBeiDeutlicherUeberplanung() + { + var date = new DateOnly(2025, 9, 1); + var periodSchedule = NewPeriodSchedule(); + periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); + vm.DateText = date.ToString("dd.MM.yyyy"); + vm.LessonNumber = 1; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 60; // 60/45 ≈ 133 % — deutlich überplant + + Assert.Equal("#B71C1C", vm.TimeBudgetColorHex); + } + + [Fact] + public void LessonNumberGeaendert_UebernimmtBeginnAusStundenraster_WennNochKeinerEingetragen() + { + var periodSchedule = NewPeriodSchedule(); + periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 5, Start = new TimeOnly(11, 45), End = new TimeOnly(12, 30) }]); + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); + + vm.LessonNumber = 5; + + Assert.Equal("11:45", vm.StartTimeText); + } + + [Fact] + public void LessonNumberGeaendert_UeberschreibtBereitsEingetragenenBeginnNicht() + { + var periodSchedule = NewPeriodSchedule(); + periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 5, Start = new TimeOnly(11, 45), End = new TimeOnly(12, 30) }]); + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); + vm.StartTimeText = "09:00"; + + vm.LessonNumber = 5; + + Assert.Equal("09:00", vm.StartTimeText); + } + + [Fact] + public void RecomputeTimeBudget_OhneStundenrasterEintrag_KeineRueckmeldung() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // leeres PeriodScheduleService + vm.DateText = "01.09.2025"; + vm.LessonNumber = 1; + vm.AddPhaseCommand.Execute(null); + + Assert.False(vm.HasTimeBudgetInfo); + } } diff --git a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs index 3b3a656..8e8be5b 100644 --- a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs @@ -181,6 +181,29 @@ public class PlanningTabViewModelTests Assert.Equal(["Arbeitsblatt", "Modell"], vm.KnownMaterials); } + [Fact] + public async Task GenerateLessonSeries_RuftDelegateMitAusgewaehlterEinheitAufUndLaedtBeiErfolgNeu() + { + var (vm, units, lessons, groupId) = BuildScenario(); + var unit = new Unit { GroupId = groupId, Title = "Optik" }; + units.Add(unit); + vm.Initialize(groupId); + vm.SelectedUnit = vm.Units.Single(u => u.Id == unit.Id); + + Unit? passedUnit = null; + vm.OnGenerateLessonSeries = u => + { + passedUnit = u; + lessons.Add(new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "" }); + return Task.FromResult(new LessonSeriesResult(1, 0, 0)); + }; + + await vm.GenerateLessonSeriesCommand.ExecuteAsync(null); + + Assert.Equal(unit.Id, passedUnit!.Id); + Assert.Single(vm.Lessons); + } + [Fact] public void LoadUnits_SammeltKurzsymboleAusAllenStundenphasenDerGruppe() { diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index bbdbaf6..67263b2 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -8,7 +8,8 @@ namespace LehrerApp.Desktop.Tests; public sealed class SettingsViewModelTests { - private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null) + private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, + FakeSupervisionDuties? supervisionDuties = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -22,7 +23,8 @@ public sealed class SettingsViewModelTests new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), - holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath)); + holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), + new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties()); } [Fact] @@ -97,10 +99,123 @@ public sealed class SettingsViewModelTests new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), - new FakeSchoolHolidays(), calendarSettings); + new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), + new FakeSupervisionDuties()); vm.SelectedStateName = "Bayern"; Assert.Equal(GermanState.BY, calendarSettings.State); } + + [Fact] + public void SavePeriodTimes_GueltigeEingabe_WirdPersistiert() + { + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-periods-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + var periodSchedule = new PeriodScheduleService(tempPath); + + var vm = new SettingsViewModel( + new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), + new FakeSchemes(), new GradingService(), new BackupService(tempPath), + new DatabaseEncryptionService(), new AppLockService(tempPath), + new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), + new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), + new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, + new FakeSupervisionDuties()); + + vm.PeriodTimes[0].StartText = "08:00"; + vm.PeriodTimes[0].EndText = "08:45"; + + vm.SavePeriodTimesCommand.Execute(null); + + Assert.Equal("", vm.PeriodTimesError); + Assert.Equal(45, periodSchedule.GetDurationMinutes(1)); + } + + [Fact] + public void SavePeriodTimes_EndeVorBeginn_SetztFehlerUndSpeichertNicht() + { + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-periods-invalid-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + var periodSchedule = new PeriodScheduleService(tempPath); + + var vm = new SettingsViewModel( + new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), + new FakeSchemes(), new GradingService(), new BackupService(tempPath), + new DatabaseEncryptionService(), new AppLockService(tempPath), + new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), + new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), + new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, + new FakeSupervisionDuties()); + + vm.PeriodTimes[0].StartText = "08:45"; + vm.PeriodTimes[0].EndText = "08:00"; + + vm.SavePeriodTimesCommand.Execute(null); + + Assert.NotEqual("", vm.PeriodTimesError); + Assert.Equal(0, periodSchedule.GetDurationMinutes(1)); + } + + [Fact] + public void AddSupervisionDuty_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt() + { + var duties = new FakeSupervisionDuties(); + var vm = BuildViewModel(supervisionDuties: duties); + vm.NewDutyWeekdayName = "Montag"; + vm.NewDutyAfterPeriod = 2; + vm.NewDutyLocation = "Pausenhof"; + + vm.AddSupervisionDutyCommand.Execute(null); + + Assert.Single(duties.GetAll()); + Assert.Single(vm.SupervisionDuties); + Assert.Equal(DayOfWeek.Monday, duties.GetAll()[0].Weekday); + Assert.Equal(2, duties.GetAll()[0].AfterPeriod); + } + + [Fact] + public void AddSupervisionDuty_FehlenderOrt_SetztFehlerUndSpeichertNicht() + { + var duties = new FakeSupervisionDuties(); + var vm = BuildViewModel(supervisionDuties: duties); + vm.NewDutyWeekdayName = "Montag"; + vm.NewDutyAfterPeriod = 2; + + vm.AddSupervisionDutyCommand.Execute(null); + + Assert.Empty(duties.GetAll()); + Assert.NotEqual("", vm.NewDutyError); + } + + [Fact] + public void AddSupervisionDuty_PauseBereitsBelegt_ZeigtFreundlicheFehlermeldung() + { + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); + var vm = BuildViewModel(supervisionDuties: duties); + vm.NewDutyWeekdayName = "Montag"; + vm.NewDutyAfterPeriod = 2; + vm.NewDutyLocation = "Bibliothek"; + + vm.AddSupervisionDutyCommand.Execute(null); + + Assert.Single(duties.GetAll()); + Assert.Equal("Für diese Pause ist bereits eine Aufsicht eingetragen.", vm.NewDutyError); + } + + [Fact] + public void RemoveSupervisionDuty_EntferntEintragAusRepositoryUndListe() + { + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); + var vm = BuildViewModel(supervisionDuties: duties); + + vm.RemoveSupervisionDutyCommand.Execute(vm.SupervisionDuties[0]); + + Assert.Empty(duties.GetAll()); + Assert.Empty(vm.SupervisionDuties); + } } diff --git a/LehrerApp.Desktop.Tests/SubstitutionEntryDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/SubstitutionEntryDialogViewModelTests.cs new file mode 100644 index 0000000..f7acd1a --- /dev/null +++ b/LehrerApp.Desktop.Tests/SubstitutionEntryDialogViewModelTests.cs @@ -0,0 +1,323 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Planning; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class SubstitutionEntryDialogViewModelTests +{ + private static SubstitutionEntryDialogViewModel BuildVm( + FakeSubstitutionEntries? substitutions = null, FakeGroups? groups = null, + FakeUnits? units = null, FakeLessons? lessons = null) => + new(substitutions ?? new FakeSubstitutionEntries(), groups ?? new FakeGroups([]), + units ?? new FakeUnits(), lessons ?? new FakeLessons()); + + [Fact] + public void Save_Aufsicht_GueltigeEingabe_ErzeugtSupervisionEntry() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Aufsicht"; + vm.AfterPeriod = 2; + vm.Description = "Vertretung für Hr. Müller"; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + var saved = Assert.Single(substitutions.GetAll()); + Assert.Equal(SubstitutionKind.Supervision, saved.Kind); + Assert.Equal(2, saved.AfterPeriod); + Assert.Equal("Vertretung für Hr. Müller", saved.Description); + } + + [Fact] + public void Save_Aufsicht_FehlenderGrund_SetztFehlerUndSpeichertNicht() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Aufsicht"; + vm.AfterPeriod = 2; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.Empty(substitutions.GetAll()); + Assert.NotEqual("", vm.ValidationError); + } + + [Fact] + public void Save_Stunde_FremdeGruppe_ErzeugtEintragOhneGroupId() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Stunde"; + vm.PeriodNumber = 3; + vm.GroupLabel = "8a"; + vm.Description = "Vertretung Erdkunde"; + + vm.SaveCommand.Execute(null); + + var saved = Assert.Single(substitutions.GetAll()); + Assert.Null(saved.GroupId); + Assert.Equal("8a", saved.GroupLabel); + Assert.Equal(SubstitutionKind.Lesson, saved.Kind); + } + + [Fact] + public void Save_Stunde_EigeneGruppeOhnePromote_ErzeugtKeineLesson() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var unit = new Unit { GroupId = group.Id, Title = "Optik" }; + var units = new FakeUnits(); units.Add(unit); + var lessons = new FakeLessons(); + var vm = BuildVm(groups: new FakeGroups([group]), units: units, lessons: lessons); + vm.DateText = "12.03.2026"; + vm.KindName = "Stunde"; + vm.PeriodNumber = 3; + vm.SelectedOwnGroup = group; + vm.Description = "Stillarbeit"; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Empty(lessons.GetByUnit(unit.Id)); + } + + [Fact] + public void Save_Stunde_EigeneGruppeMitPromote_ErzeugtZusaetzlichLesson() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var unit = new Unit { GroupId = group.Id, Title = "Optik" }; + var units = new FakeUnits(); units.Add(unit); + var lessons = new FakeLessons(); + var vm = BuildVm(groups: new FakeGroups([group]), units: units, lessons: lessons); + vm.DateText = "12.03.2026"; + vm.KindName = "Stunde"; + vm.PeriodNumber = 3; + vm.SelectedOwnGroup = group; // füllt UnitsOfSelectedGroup + SelectedUnit + vm.Description = "Brechung (vorgezogen)"; + vm.PromoteToLesson = true; + + vm.SaveCommand.Execute(null); + + var lesson = Assert.Single(lessons.GetByUnit(unit.Id)); + Assert.Equal(group.Id, lesson.GroupId); + Assert.Equal(3, lesson.LessonNumber); + Assert.Equal("Brechung (vorgezogen)", lesson.Topic); + Assert.Equal(new DateOnly(2026, 3, 12), lesson.Date); + } + + [Fact] + public void SelectedOwnGroupChanged_FuelltGroupLabelUndEinheitenListe() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var unit = new Unit { GroupId = group.Id, Title = "Optik" }; + var units = new FakeUnits(); units.Add(unit); + var vm = BuildVm(groups: new FakeGroups([group]), units: units); + + vm.SelectedOwnGroup = group; + + Assert.Equal("Q1 Chemie", vm.GroupLabel); + Assert.Single(vm.UnitsOfSelectedGroup); + Assert.Equal(unit.Id, vm.SelectedUnit!.Id); + } + + [Fact] + public void CanPromoteToLesson_FalseWennGruppeKeineEinheitenHat() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var vm = BuildVm(groups: new FakeGroups([group])); + vm.KindName = "Stunde"; + + vm.SelectedOwnGroup = group; + + Assert.False(vm.CanPromoteToLesson); + } + + [Fact] + public void Save_UngueltigesDatum_SetztFehler() + { + var vm = BuildVm(); + vm.DateText = "keinDatum"; + vm.KindName = "Aufsicht"; + vm.Description = "x"; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.DateError); + } + + // ── Sondereinsatz ───────────────────────────────────────────────────────── + + [Fact] + public void Save_Sondereinsatz_Ganztaegig_ErzeugtEintragOhneStundenbereich() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Sondereinsatz"; + vm.IsAllDay = true; + vm.Description = "Ausflug ins Museum"; + + vm.SaveCommand.Execute(null); + + var saved = Assert.Single(substitutions.GetAll()); + Assert.Equal(SubstitutionKind.SpecialAssignment, saved.Kind); + Assert.True(saved.IsAllDay); + Assert.Null(saved.FromPeriod); + Assert.Null(saved.ToPeriod); + } + + [Fact] + public void Save_Sondereinsatz_MitStundenbereich_SpeichertVonBis() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Sondereinsatz"; + vm.IsAllDay = false; + vm.FromPeriod = 3; + vm.ToPeriod = 4; + vm.Description = "Berufsmesse"; + + vm.SaveCommand.Execute(null); + + var saved = Assert.Single(substitutions.GetAll()); + Assert.False(saved.IsAllDay); + Assert.Equal(3, saved.FromPeriod); + Assert.Equal(4, saved.ToPeriod); + } + + [Fact] + public void Save_Sondereinsatz_BisVorVon_SetztFehler() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Sondereinsatz"; + vm.IsAllDay = false; + vm.FromPeriod = 5; + vm.ToPeriod = 2; + vm.Description = "Berufsmesse"; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.Empty(substitutions.GetAll()); + Assert.NotEqual("", vm.ValidationError); + } + + [Fact] + public void Save_Sondereinsatz_OhneBeschreibung_SetztFehler() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Sondereinsatz"; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.Empty(substitutions.GetAll()); + Assert.NotEqual("", vm.ValidationError); + } + + [Fact] + public void Save_Sondereinsatz_OhneGruppe_SpeichertLeeresGroupLabel() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Sondereinsatz"; + vm.Description = "Berufsmesse"; + + vm.SaveCommand.Execute(null); + + var saved = Assert.Single(substitutions.GetAll()); + Assert.Null(saved.GroupId); + Assert.Equal("", saved.GroupLabel); + } + + [Fact] + public void CanPromoteToLesson_FalseFuerSondereinsatz() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var unit = new Unit { GroupId = group.Id, Title = "Optik" }; + var units = new FakeUnits(); units.Add(unit); + var vm = BuildVm(groups: new FakeGroups([group]), units: units); + vm.KindName = "Sondereinsatz"; + + vm.SelectedOwnGroup = group; + + Assert.False(vm.CanPromoteToLesson); + } + + // ── Ausfall ─────────────────────────────────────────────────────────────── + + [Fact] + public void Save_Ausfall_GueltigeEingabe_ErzeugtEintragMitPeriodeUndGrund() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Ausfall"; + vm.PeriodNumber = 2; + vm.Description = "6a auf Klassenfahrt"; + + vm.SaveCommand.Execute(null); + + var saved = Assert.Single(substitutions.GetAll()); + Assert.Equal(SubstitutionKind.Cancelled, saved.Kind); + Assert.Equal(2, saved.PeriodNumber); + Assert.Equal("6a auf Klassenfahrt", saved.Description); + } + + [Fact] + public void Save_Ausfall_OhneGrund_IstErlaubt() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Ausfall"; + vm.PeriodNumber = 2; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Single(substitutions.GetAll()); + } + + [Fact] + public void Save_Ausfall_UngueltigePeriode_SetztFehler() + { + var substitutions = new FakeSubstitutionEntries(); + var vm = BuildVm(substitutions); + vm.DateText = "12.03.2026"; + vm.KindName = "Ausfall"; + vm.PeriodNumber = 11; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.Empty(substitutions.GetAll()); + Assert.NotEqual("", vm.ValidationError); + } + + [Fact] + public void CanPromoteToLesson_FalseFuerAusfall() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var unit = new Unit { GroupId = group.Id, Title = "Optik" }; + var units = new FakeUnits(); units.Add(unit); + var vm = BuildVm(groups: new FakeGroups([group]), units: units); + vm.KindName = "Ausfall"; + + vm.SelectedOwnGroup = group; + + Assert.False(vm.CanPromoteToLesson); + } +} diff --git a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs index eefef35..c83ea8f 100644 --- a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs @@ -10,7 +10,8 @@ public sealed class TimetableViewModelTests private static TimetableViewModel BuildViewModel( FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null, FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null, - SchoolCalendarSettingsService? calendarSettings = null) + SchoolCalendarSettingsService? calendarSettings = null, + FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf // (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -22,7 +23,8 @@ public sealed class TimetableViewModelTests lessons ?? new FakeLessons(), exams ?? new FakeExams([]), holidays ?? new FakeSchoolHolidays(), calendarSettings ?? new SchoolCalendarSettingsService(tempPath), - new PublicHolidayService(), new SchoolYearService()); + new PublicHolidayService(), new SchoolYearService(), + supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries()); } /// Nächstes Datum ab (inkl.) , das auf einen Wochentag Mo-Fr fällt — @@ -410,4 +412,334 @@ public sealed class TimetableViewModelTests var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1); Assert.False(cell.HasBadge); } + + // ── Aufsicht: wiederkehrend (Bearbeiten-Raster) ────────────────────────── + + [Fact] + public void Load_AufsichtEingetragen_ZeigtSupervisionRowMitOrt() + { + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), supervisionDuties: duties); + + var label = vm.Cells.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 2.")); + Assert.NotNull(label); + var mondayCell = vm.Cells + .SkipWhile(c => c != label).Skip(1) // erste Zelle nach dem Label = Montag + .First(); + Assert.True(mondayCell.IsSupervisionCell); + Assert.Equal("Pausenhof", mondayCell.SupervisionLocation); + } + + [Fact] + public void Load_KeineAufsichtEingetragen_KeineSupervisionRow() + { + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); + + Assert.DoesNotContain(vm.Cells, c => c.IsSupervisionRow); + } + + // ── Aufsicht + Vertretung im Wochenraster ──────────────────────────────── + + [Fact] + public void Load_Wochenraster_ZeigtWiederkehrendeAufsicht() + { + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), supervisionDuties: duties); + + var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 2.")); + var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First(); + Assert.Equal("Pausenhof", mondayCell.SupervisionLocation); + Assert.False(mondayCell.IsSubstitutionSupervision); + } + + [Fact] + public void Load_Wochenraster_VertretungsaufsichtUeberschreibtRegulaereAnzeige() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = 2, + Description = "Vertretung für Hr. Müller", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), + supervisionDuties: duties, substitutions: substitutions); + + var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 2.")); + var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First(); + Assert.Equal("Vertretung für Hr. Müller", mondayCell.SupervisionLocation); + Assert.True(mondayCell.IsSubstitutionSupervision); + } + + [Fact] + public void Load_Wochenraster_VertretungsaufsichtOhneRegulaereAufsicht_ZeigtEigeneRow() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = 3, + Description = "Vertretung für Fr. Schmidt", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 3.")); + var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First(); + Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation); + } + + [Fact] + public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var date = DateInCurrentWeek(DayOfWeek.Monday); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.Lesson, PeriodNumber = 1, + GroupLabel = "8a", Description = "Vertretung Erdkunde", + }); + var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.True(cell.IsSubstitutionLesson); + Assert.Equal("8a", cell.GroupName); + Assert.Equal("Vertretung Erdkunde", cell.Topic); + } + + [Fact] + public void Load_Wochenraster_VertretungsstundeOhnePassendenSlot_ErscheintTrotzdem() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.Lesson, PeriodNumber = 5, + GroupLabel = "8a", Description = "Vertretung Erdkunde", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 5); + Assert.True(cell.IsSubstitutionLesson); + Assert.True(cell.IsAssigned); + } + + // ── Aufsicht + Vertretung in der Tagesliste ("Heute") ──────────────────── + + [Fact] + public void Load_Heute_ZeigtHeutigeAufsicht() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = today.DayOfWeek, AfterPeriod = 2, Location = "Pausenhof" }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), supervisionDuties: duties); + + var item = Assert.Single(vm.TodaySupervisions); + Assert.Equal("Pausenhof", item.Description); + Assert.False(item.IsSubstitution); + } + + [Fact] + public void Load_Heute_VertretungsstundeErsetztNormaleStundenanzeige() + { + 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 = 1 }); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = today, Kind = SubstitutionKind.Lesson, PeriodNumber = 1, + GroupLabel = "8a", Description = "Vertretung Erdkunde", + }); + var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions); + + var item = Assert.Single(vm.TodayItems); + Assert.True(item.IsSubstitution); + Assert.Equal("8a", item.GroupName); + Assert.Equal("Vertretung Erdkunde", item.LessonTopic); + } + + [Fact] + public void OpenGroup_MitLeererGroupId_RuftDelegateNichtAuf() + { + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); + var called = false; + vm.OnNavigateToGroup = _ => called = true; + + vm.OpenGroupCommand.Execute(Guid.Empty); + + Assert.False(called); + } + + // ── Sondereinsätze (Ausflüge, Berufsmessen, ...) ───────────────────────── + + [Fact] + public void Load_Wochenraster_GanztaegigerSondereinsatz_UeberdecktAllePeriodenDesTages() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = true, + GroupLabel = "8a", Description = "Ausflug ins Museum", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + var period1 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + var period7 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 7); + Assert.True(period1.IsSpecialAssignment); + Assert.True(period7.IsSpecialAssignment); + Assert.Equal("Ausflug ins Museum", period1.Topic); + Assert.Equal("8a", period1.GroupName); + } + + [Fact] + public void Load_Wochenraster_SondereinsatzMitStundenbereich_NurDortSichtbar() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = false, + FromPeriod = 3, ToPeriod = 4, Description = "Berufsmesse", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + var period2 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 2); + var period3 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 3); + var period4 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 4); + var period5 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 5); + Assert.False(period2.IsSpecialAssignment); + Assert.True(period3.IsSpecialAssignment); + Assert.True(period4.IsSpecialAssignment); + Assert.False(period5.IsSpecialAssignment); + } + + [Fact] + public void Load_Wochenraster_SondereinsatzOhneGruppe_ZeigtLeeresGroupName() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = true, Description = "Berufsmesse", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.Equal("", cell.GroupName); + } + + [Fact] + public void Load_Heute_ZeigtGanztaegigenSondereinsatz() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = today, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = true, + GroupLabel = "8a", Description = "Ausflug ins Museum", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + var item = Assert.Single(vm.TodaySpecialAssignments); + Assert.Equal("Ganztägig", item.PeriodLabel); + Assert.Equal("Ausflug ins Museum", item.Description); + Assert.Equal("8a", item.GroupLabel); + } + + [Fact] + public void Load_Heute_ZeigtSondereinsatzMitStundenbereich() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = today, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = false, + FromPeriod = 3, ToPeriod = 4, Description = "Berufsmesse", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + var item = Assert.Single(vm.TodaySpecialAssignments); + Assert.Equal("3.–4. Stunde", item.PeriodLabel); + } + + // ── Stundenausfall ──────────────────────────────────────────────────────── + + [Fact] + public void Load_Wochenraster_Ausfall_ZeigtGruppeUndFachAusDemStundenplan() + { + var subject = new Subject { Name = "Naturwissenschaften", ShortName = "NAT" }; + var group = new LearningGroup { Name = "6a", SubjectId = subject.Id }; + var date = DateInCurrentWeek(DayOfWeek.Monday); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 }); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = 2, Description = "6a auf Klassenfahrt", + }); + var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), substitutions: substitutions); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 2); + Assert.True(cell.IsCancelled); + Assert.Equal("NAT", cell.SubjectLabel); + Assert.Equal("6a", cell.GroupName); + Assert.Equal("6a auf Klassenfahrt", cell.Topic); + } + + [Fact] + public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde() + { + var group = new LearningGroup { Name = "6a" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 2); + Assert.False(cell.IsCancelled); + } + + [Fact] + public void Load_Heute_Ausfall_ErsetztNormaleStundenanzeige() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "6a" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 2 }); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 2, Description = "6a auf Klassenfahrt", + }); + var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions); + + var item = Assert.Single(vm.TodayItems); + Assert.True(item.IsCancelled); + Assert.Equal("6a", item.GroupName); + Assert.Equal("6a auf Klassenfahrt", item.LessonTopic); + Assert.Equal(group.Id, item.GroupId); + } + + [Fact] + public void Load_Heute_AusfallOhneGrund_LessonTopicBleibtLeer() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "6a" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 2 }); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 2 }); + var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions); + + var item = Assert.Single(vm.TodayItems); + Assert.False(item.HasLessonTopic); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index a6484f9..4c53603 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -133,12 +133,15 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(_ => new SchoolCalendarSettingsService(appData)); + services.AddSingleton(_ => new PeriodScheduleService(appData)); // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── services.AddSingleton(_ => new EventQueue(queuePath)); diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 5cf55c5..32820ce 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -2,16 +2,24 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Core.Services; using System.Collections.ObjectModel; using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Groups; -// ── Ergebnisse der Verschieben-/Kopieren-Dialoge (4.2.4 / 4.1.4) ───────────── +// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ─── public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing); public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate); +public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting) +{ + public string Summary => $"{Created} Stunde(n) angelegt" + + (SkippedHoliday > 0 ? $", {SkippedHoliday} durch Ferien/Feiertage übersprungen" : "") + + (SkippedExisting > 0 ? $", {SkippedExisting} bereits vorhanden" : "") + "."; +} + // ── Tab-ViewModel: Unterrichtsplanung (4.1 Einheiten / 4.2 Einzelstunden) ──── public partial class PlanningTabViewModel : ObservableObject @@ -56,6 +64,7 @@ public partial class PlanningTabViewModel : ObservableObject public Func>? OnConfirmDeleteLesson { get; set; } public Func>? OnPickMoveTarget { get; set; } public Func? OnShowLesson { get; set; } + public Func>? OnGenerateLessonSeries { get; set; } public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, ISubjectRepository subjects, @@ -109,6 +118,7 @@ public partial class PlanningTabViewModel : ObservableObject DeleteUnitCommand.NotifyCanExecuteChanged(); CopyUnitCommand.NotifyCanExecuteChanged(); AddLessonCommand.NotifyCanExecuteChanged(); + GenerateLessonSeriesCommand.NotifyCanExecuteChanged(); } private void LoadLessons() @@ -231,6 +241,16 @@ public partial class PlanningTabViewModel : ObservableObject if (await OnAddLesson(SelectedUnit.Id, _groupId, KnownMaterials, KnownShorthands)) LoadUnits(); } + /// Serienerzeugung von Stunden aus dem Stundenplan (4.2.5) — die eigentliche Logik läuft im + /// Dialog (), hier wird nur nachgeladen. + [RelayCommand(CanExecute = nameof(HasSelectedUnit))] + private async Task GenerateLessonSeries() + { + if (OnGenerateLessonSeries is null || SelectedUnit is null) return; + var result = await OnGenerateLessonSeries(SelectedUnit.Model); + if (result is not null) LoadUnits(); + } + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task EditLesson() { @@ -558,6 +578,8 @@ public partial class LessonDialogViewModel : ObservableObject { private readonly ILessonRepository _lessons; private readonly IAlternativeLessonPathRepository _alternativePaths; + private readonly ITimetableSlotRepository _timetableSlots; + private readonly PeriodScheduleService _periodSchedule; private readonly Guid _unitId; private readonly Guid _groupId; private readonly Lesson? _editingLesson; @@ -573,6 +595,9 @@ public partial class LessonDialogViewModel : ObservableObject [ObservableProperty] private string _topicError = ""; [ObservableProperty] private string _startTimeTextError = ""; [ObservableProperty] private string _totalDurationDisplay = "0 Minuten gesamt"; + [ObservableProperty] private string _timeBudgetLabel = ""; + [ObservableProperty] private string _timeBudgetColorHex = "#9E9E9E"; + [ObservableProperty] private bool _hasTimeBudgetInfo; public string[] StatusOptions => LessonStatusDisplay.Options; public string[] MaterialSuggestions { get; } @@ -589,10 +614,12 @@ public partial class LessonDialogViewModel : ObservableObject public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern"; public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes, - IAlternativeLessonPathRepository alternativePaths, Guid unitId, Guid groupId, + IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots, + PeriodScheduleService periodSchedule, Guid unitId, Guid groupId, List materialSuggestions, List shorthandHistorySuggestions, Lesson? editingLesson) { _lessons = lessons; _alternativePaths = alternativePaths; + _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson; MaterialSuggestions = [.. materialSuggestions]; @@ -672,6 +699,18 @@ public partial class LessonDialogViewModel : ObservableObject } partial void OnStartTimeTextChanged(string value) => RecomputeTimes(); + /// Übernimmt beim Wählen der Stundennummer auch gleich den Beginn aus dem Stundenraster + /// (Einstellungen), sofern noch keiner eingetragen ist — beim Laden einer vorhandenen Stunde + /// wird das direkt danach vom tatsächlich gespeicherten StartTime überschrieben (auch + /// wenn das "kein Beginn hinterlegt" bedeutet), ein bereits eingetippter Beginn bleibt unangetastet. + partial void OnLessonNumberChanged(int? value) + { + if (value is int period && string.IsNullOrWhiteSpace(StartTimeText) && + _periodSchedule.GetTimes(period) is { } times) + StartTimeText = times.Start.ToString("HH:mm"); + RecomputeTimes(); + } + partial void OnDateTextChanged(string value) => RecomputeTimes(); /// Dauer ist die primäre Eingabe je Phase; die Uhrzeit wird daraus nur zur Anzeige /// abgeleitet — kumulativ ab "Beginn", sofern gesetzt (sonst bleibt sie leer). @@ -690,8 +729,61 @@ public partial class LessonDialogViewModel : ObservableObject p.ComputedTimeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : ""; if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes); } + + RecomputeTimeBudget(total); } + /// + /// Vergleicht die geplante Gesamtdauer mit der laut Stundenraster (Einstellungen) tatsächlich + /// verfügbaren Zeit — Doppelstunden werden erkannt, indem ab der eingetragenen Stundennummer + /// so lange die jeweils nächste Periode addiert wird, wie der Stundenplan (4.3) für dieselbe + /// Gruppe/denselben Wochentag auch dort einen Slot hat (siehe ). + /// Ohne erkennbare Stunde/Datum oder ohne im Stundenraster hinterlegte Uhrzeiten bleibt die + /// Rückmeldung schlicht ausgeblendet, statt eine erfundene Dauer vorzutäuschen. + /// + private void RecomputeTimeBudget(int plannedMinutes) + { + if (LessonNumber is not int startPeriod || + !DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) + { HasTimeBudgetInfo = false; return; } + + var weekday = date.DayOfWeek; + var groupSlotsByPeriod = _timetableSlots.GetByGroup(_groupId) + .Where(s => s.Weekday == weekday) + .ToDictionary(s => s.PeriodNumber); + + var available = _periodSchedule.GetDurationMinutes(startPeriod); + var period = startPeriod + 1; + while (groupSlotsByPeriod.ContainsKey(period)) + { + available += _periodSchedule.GetDurationMinutes(period); + period++; + } + + if (available <= 0) { HasTimeBudgetInfo = false; return; } + + var utilizationPercent = (double)plannedMinutes / available * 100; + TimeBudgetColorHex = TimeBudgetColor(utilizationPercent); + TimeBudgetLabel = $"{plannedMinutes} von {available} Minuten geplant ({utilizationPercent:0}%)"; + HasTimeBudgetInfo = true; + } + + /// + /// Farbskala für die Auslastung (geplante / verfügbare Minuten): 93–96 % gilt als guter + /// Zielbereich (grün) — ein kleiner Puffer, da 100 % erfahrungsgemäß schon knapp ist. Darüber + /// wird es zunehmend rötlich, deutlich über 100 % kräftig rot. Deutlich unter 93 % (zu viel + /// Luft) ist bewusst neutral/blau statt rot gehalten — kein Fehler, nur "hier geht noch was". + /// + private static string TimeBudgetColor(double utilizationPercent) => utilizationPercent switch + { + < 70 => "#90A4AE", // Blaugrau: deutlich zu wenig geplant + < 93 => "#FFC107", // Gelb: noch Luft nach oben + <= 96 => "#43A047", // Grün: guter Zielbereich + <= 100 => "#FB8C00", // Orange: knapp, kaum Puffer + <= 115 => "#E64A19", // Rotorange: leicht überplant + _ => "#B71C1C", // Dunkelrot: deutlich überplant + }; + [RelayCommand] private void Save() { @@ -886,6 +978,93 @@ public partial class MoveLessonDialogViewModel : ObservableObject } } +// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ──────── + +public partial class GenerateLessonSeriesDialogViewModel : ObservableObject +{ + private readonly ITimetableSlotRepository _slots; + private readonly ILessonRepository _lessons; + private readonly ISchoolHolidayRepository _schoolHolidays; + private readonly PublicHolidayService _publicHolidays; + private readonly SchoolCalendarSettingsService _calendarSettings; + private readonly Guid _unitId; + private readonly Guid _groupId; + + [ObservableProperty] private string _fromDateText; + [ObservableProperty] private string _toDateText; + [ObservableProperty] private string _dateError = ""; + + public LessonSeriesResult? Result { get; private set; } + + public GenerateLessonSeriesDialogViewModel(ITimetableSlotRepository slots, ILessonRepository lessons, + ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, + SchoolCalendarSettingsService calendarSettings, Guid unitId, Guid groupId, + DateOnly? defaultFrom, DateOnly? defaultTo) + { + _slots = slots; _lessons = lessons; _schoolHolidays = schoolHolidays; + _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; + _unitId = unitId; _groupId = groupId; + _fromDateText = (defaultFrom ?? DateOnly.FromDateTime(DateTime.Today)).ToString("dd.MM.yyyy"); + _toDateText = (defaultTo ?? DateOnly.FromDateTime(DateTime.Today).AddMonths(1)).ToString("dd.MM.yyyy"); + } + + /// Legt für jeden Wochentag/Stunde, den die Gruppe laut Stundenplan (4.3) hat, im gewählten + /// Zeitraum eine neue Lesson an. Schulferien/Feiertage werden übersprungen (dieselbe Prüfung + /// wie im Stundenplan-Wochenraster, siehe TimetableViewModel.IsFreeDay); für ein Datum, an dem + /// die Gruppe laut Stundenplan bereits eine Lesson hat (gleiches Datum + gleiche Stundennummer, + /// unabhängig von der Einheit — ein Lehrer kann an einem Termin nur eine tatsächliche Stunde + /// halten), wird nichts doppelt angelegt. Neue Stunden bekommen bewusst kein Thema — die + /// sonst übliche "Thema erforderlich"-Regel des manuellen "+Stunde"-Dialogs gilt hier nicht, + /// da diese Platzhalter zum späteren Ausfüllen gedacht sind. + [RelayCommand] + private void Save() + { + DateError = ""; + + if (!DateOnly.TryParseExact(FromDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) || + !DateOnly.TryParseExact(ToDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to)) + { DateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; return; } + if (to < from) { DateError = "Das Ende darf nicht vor dem Beginn liegen."; return; } + + var slotsForGroup = _slots.GetByGroup(_groupId); + if (slotsForGroup.Count == 0) + { DateError = "Für diese Gruppe ist noch keine Stunde im Stundenplan eingetragen."; return; } + + var schoolHolidays = _schoolHolidays.GetAll(); + var publicHolidayDates = new HashSet(); + for (var year = from.Year; year <= to.Year; year++) + foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date); + + var existing = _lessons.GetByGroupAndRange(_groupId, from, to) + .Select(l => (l.Date, l.LessonNumber)).ToHashSet(); + + int created = 0, skippedHoliday = 0, skippedExisting = 0; + for (var date = from; date <= to; date = date.AddDays(1)) + { + var isFreeDay = publicHolidayDates.Contains(date) || + schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate); + + foreach (var slot in slotsForGroup.Where(s => s.Weekday == date.DayOfWeek)) + { + if (isFreeDay) { skippedHoliday++; continue; } + if (existing.Contains((date, (int?)slot.PeriodNumber))) { skippedExisting++; continue; } + + _lessons.Save(new Lesson + { + UnitId = _unitId, + GroupId = _groupId, + Date = date, + LessonNumber = slot.PeriodNumber, + Topic = "", + }); + created++; + } + } + + Result = new LessonSeriesResult(created, skippedHoliday, skippedExisting); + } +} + // ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ──────────── public partial class CopyUnitDialogViewModel : ObservableObject diff --git a/LehrerApp.Desktop/ViewModels/Planning/SubstitutionEntryDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/SubstitutionEntryDialogViewModel.cs new file mode 100644 index 0000000..4f2807e --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Planning/SubstitutionEntryDialogViewModel.cs @@ -0,0 +1,176 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Planning; + +public static class SubstitutionKindDisplay +{ + public static string[] Options { get; } = ["Aufsicht", "Stunde", "Sondereinsatz", "Ausfall"]; + + public static string ToName(SubstitutionKind k) => k switch + { + SubstitutionKind.Lesson => "Stunde", + SubstitutionKind.SpecialAssignment => "Sondereinsatz", + SubstitutionKind.Cancelled => "Ausfall", + _ => "Aufsicht", + }; + + public static SubstitutionKind FromName(string? name) => name switch + { + "Stunde" => SubstitutionKind.Lesson, + "Sondereinsatz" => SubstitutionKind.SpecialAssignment, + "Ausfall" => SubstitutionKind.Cancelled, + _ => SubstitutionKind.Supervision, + }; +} + +/// +/// Dialog "Vertretung eintragen" (4.3 Nachtrag, Sonderfälle): einmalige Vertretungsaufsicht, +/// Vertretungsstunde in einer eigenen oder fremden Lerngruppe, ein Sondereinsatz (Ausflug, +/// Berufsmesse, Exkursion, ...), oder ein schlichter Stundenausfall (z.B. weil die betroffene +/// Gruppe selbst auf Klassenfahrt ist — nicht die eigene Abwesenheit, sondern die der Gruppe). +/// Bei eigener Gruppe bleibt der einfache Weg (nur Plan-Eintrag mit Thema) der Standard — +/// "Direkt als Stunde in der Einheit übernehmen" ist ein bewusstes Opt-in, nur bei +/// Vertretungsstunden, damit die meist beiläufigen Vertretungsstunden nicht automatisch die +/// Fortschrittsanzeige/Reihenfolge der Einheit durcheinanderbringen (siehe TODO.md, Nachtrag zu +/// 4.3). Sondereinsatz und Ausfall sind bewusst nie als Einheiten-Stunde übernehmbar — ein +/// Ausflug ist inhaltlich kein Verlaufsplan-Eintrag, und ein Ausfall ist per Definition keine +/// gehaltene Stunde. +/// +public partial class SubstitutionEntryDialogViewModel : ObservableObject +{ + private readonly ISubstitutionEntryRepository _substitutions; + private readonly IUnitRepository _units; + private readonly ILessonRepository _lessons; + + [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); + [ObservableProperty] private string _kindName = SubstitutionKindDisplay.Options[0]; + [ObservableProperty] private int _afterPeriod; + [ObservableProperty] private int _periodNumber = 1; + [ObservableProperty] private bool _isAllDay = true; + [ObservableProperty] private int _fromPeriod = 1; + [ObservableProperty] private int _toPeriod = 10; + [ObservableProperty] private LearningGroup? _selectedOwnGroup; + [ObservableProperty] private string _groupLabel = ""; + [ObservableProperty] private string _description = ""; + [ObservableProperty] private bool _promoteToLesson; + [ObservableProperty] private Unit? _selectedUnit; + [ObservableProperty] private string _dateError = ""; + [ObservableProperty] private string _validationError = ""; + + public string[] KindOptions => SubstitutionKindDisplay.Options; + public bool IsSupervisionKind => KindName == "Aufsicht"; + public bool IsLessonKind => KindName == "Stunde"; + public bool IsSpecialAssignmentKind => KindName == "Sondereinsatz"; + public bool IsCancelledKind => KindName == "Ausfall"; + public bool CanPromoteToLesson => IsLessonKind && SelectedOwnGroup is not null && UnitsOfSelectedGroup.Count > 0; + + public ObservableCollection OwnGroups { get; } = []; + public ObservableCollection UnitsOfSelectedGroup { get; } = []; + + public SubstitutionEntry? Result { get; private set; } + + public SubstitutionEntryDialogViewModel(ISubstitutionEntryRepository substitutions, IGroupRepository groups, + IUnitRepository units, ILessonRepository lessons) + { + _substitutions = substitutions; _units = units; _lessons = lessons; + foreach (var g in groups.GetAll().OrderBy(g => g.Name)) OwnGroups.Add(g); + } + + partial void OnKindNameChanged(string value) + { + OnPropertyChanged(nameof(IsSupervisionKind)); + OnPropertyChanged(nameof(IsLessonKind)); + OnPropertyChanged(nameof(IsSpecialAssignmentKind)); + OnPropertyChanged(nameof(IsCancelledKind)); + OnPropertyChanged(nameof(CanPromoteToLesson)); + } + + partial void OnSelectedOwnGroupChanged(LearningGroup? value) + { + UnitsOfSelectedGroup.Clear(); + if (value is not null) + { + GroupLabel = value.Name; + foreach (var u in _units.GetByGroup(value.Id)) UnitsOfSelectedGroup.Add(u); + } + SelectedUnit = UnitsOfSelectedGroup.FirstOrDefault(); + PromoteToLesson = false; + OnPropertyChanged(nameof(CanPromoteToLesson)); + } + + [RelayCommand] + private void Save() + { + DateError = ""; ValidationError = ""; + + if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { DateError = "Format TT.MM.JJJJ."; return; } + + var kind = SubstitutionKindDisplay.FromName(KindName); + var entry = new SubstitutionEntry { Date = date, Kind = kind }; + + switch (kind) + { + case SubstitutionKind.Supervision: + if (AfterPeriod is < 0 or > 10) { ValidationError = "Pause muss zwischen 0 und 10 liegen."; return; } + if (string.IsNullOrWhiteSpace(Description)) { ValidationError = "Grund/Ort erforderlich."; return; } + entry.AfterPeriod = AfterPeriod; + entry.Description = Description.Trim(); + break; + + case SubstitutionKind.Lesson: + if (PeriodNumber is < 1 or > 10) { ValidationError = "Stunde muss zwischen 1 und 10 liegen."; return; } + if (string.IsNullOrWhiteSpace(GroupLabel)) { ValidationError = "Gruppe/Bezeichnung erforderlich."; return; } + if (string.IsNullOrWhiteSpace(Description)) { ValidationError = "Thema erforderlich."; return; } + entry.PeriodNumber = PeriodNumber; + entry.GroupId = SelectedOwnGroup?.Id; + entry.GroupLabel = GroupLabel.Trim(); + entry.Description = Description.Trim(); + break; + + case SubstitutionKind.SpecialAssignment: + if (string.IsNullOrWhiteSpace(Description)) { ValidationError = "Bezeichnung erforderlich."; return; } + if (!IsAllDay) + { + if (FromPeriod is < 1 or > 10 || ToPeriod is < 1 or > 10) + { ValidationError = "Stunden müssen zwischen 1 und 10 liegen."; return; } + if (ToPeriod < FromPeriod) + { ValidationError = "Die Bis-Stunde darf nicht vor der Von-Stunde liegen."; return; } + entry.FromPeriod = FromPeriod; + entry.ToPeriod = ToPeriod; + } + entry.IsAllDay = IsAllDay; + entry.GroupId = SelectedOwnGroup?.Id; + entry.GroupLabel = GroupLabel.Trim(); + entry.Description = Description.Trim(); + break; + + case SubstitutionKind.Cancelled: + if (PeriodNumber is < 1 or > 10) { ValidationError = "Stunde muss zwischen 1 und 10 liegen."; return; } + entry.PeriodNumber = PeriodNumber; + entry.Description = Description.Trim(); + break; + } + + _substitutions.Save(entry); + + if (kind == SubstitutionKind.Lesson && PromoteToLesson && SelectedOwnGroup is not null && SelectedUnit is not null) + { + _lessons.Save(new Lesson + { + UnitId = SelectedUnit.Id, + GroupId = SelectedOwnGroup.Id, + Date = date, + LessonNumber = PeriodNumber, + Topic = Description.Trim(), + }); + } + + Result = entry; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index 81bb4f8..f4deac5 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -58,11 +58,15 @@ public partial class TimetableViewModel : ObservableObject private readonly SchoolCalendarSettingsService _calendarSettings; private readonly PublicHolidayService _publicHolidays; private readonly SchoolYearService _schoolYear; + private readonly ISupervisionDutyRepository _supervisionDuties; + private readonly ISubstitutionEntryRepository _substitutions; public ObservableCollection Cells { get; } = []; public ObservableCollection WeekItems { get; } = []; public ObservableCollection HoursWarnings { get; } = []; public ObservableCollection TodayItems { get; } = []; + public ObservableCollection TodaySupervisions { get; } = []; + public ObservableCollection TodaySpecialAssignments { get; } = []; [ObservableProperty] private int _activeTabIndex; [ObservableProperty] private string _todayLabel = ""; @@ -72,15 +76,18 @@ public partial class TimetableViewModel : ObservableObject public Func? OnEditSlot { get; set; } public Action? OnNavigateToGroup { get; set; } + public Func? OnAddSubstitution { get; set; } public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, - PublicHolidayService publicHolidays, SchoolYearService schoolYear) + PublicHolidayService publicHolidays, SchoolYearService schoolYear, + ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions) { _slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; _publicHolidays = publicHolidays; _schoolYear = schoolYear; + _supervisionDuties = supervisionDuties; _substitutions = substitutions; Load(); } @@ -95,6 +102,14 @@ public partial class TimetableViewModel : ObservableObject [RelayCommand] private void CurrentWeek() { WeekOffset = 0; Load(); } + [RelayCommand] + private async Task AddSubstitution() + { + if (OnAddSubstitution is null) return; + await OnAddSubstitution(); + Load(); + } + public void Load() { var today = DateOnly.FromDateTime(DateTime.Today); @@ -106,33 +121,90 @@ public partial class TimetableViewModel : ObservableObject var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates); var examProximity = ComputeExamProximity(today, publicHolidayDates); + var duties = _supervisionDuties.GetAll(); - BuildGrid(holidayBadges); - BuildWeekOverview(today, publicHolidayDates); + BuildGrid(holidayBadges, duties); + BuildWeekOverview(today, publicHolidayDates, duties); BuildToday(today); BuildHoursWarnings(); } // ── "Heute": Tagesliste ────────────────────────────────────────────────── + /// + /// Vertretungsstunden (, Kind Lesson) überschreiben für ihre + /// Stunde die normale, aus dem Stundenplan abgeleitete Anzeige — sie beschreiben, was an + /// diesem konkreten Tag tatsächlich stattfindet. Stunden ohne passenden Stundenplan-Slot (z.B. + /// Vertretung in einer fremden Gruppe) werden zusätzlich angehängt. + /// private void BuildToday(DateOnly today) { - TodayItems.Clear(); - var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).OrderBy(s => s.PeriodNumber).ToList(); + var substitutionsToday = _substitutions.GetByDate(today); + var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).ToList(); + var items = new List(); + var coveredPeriods = new HashSet(); foreach (var slot in slotsToday) { + coveredPeriods.Add(slot.PeriodNumber); + var lessonSub = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Lesson && s.PeriodNumber == slot.PeriodNumber); + if (lessonSub is not null) { items.Add(TodayLessonItem.ForSubstitution(slot.PeriodNumber, lessonSub)); continue; } + var group = _groups.GetById(slot.GroupId); if (group is null) continue; + + 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 exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today); - TodayItems.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name, + items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name, slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title)); } + + foreach (var sub in substitutionsToday.Where(s => s.Kind == SubstitutionKind.Lesson && + s.PeriodNumber is int p && !coveredPeriods.Contains(p))) + items.Add(TodayLessonItem.ForSubstitution(sub.PeriodNumber!.Value, sub)); + + TodayItems.Clear(); + foreach (var item in items.OrderBy(i => i.PeriodNumber)) TodayItems.Add(item); + + TodaySupervisions.Clear(); + foreach (var item in BuildTodaySupervisionItems(today, substitutionsToday)) TodaySupervisions.Add(item); + + TodaySpecialAssignments.Clear(); + foreach (var sub in substitutionsToday.Where(s => s.Kind == SubstitutionKind.SpecialAssignment)) + { + var periodLabel = sub.IsAllDay ? "Ganztägig" : $"{sub.FromPeriod}.–{sub.ToPeriod}. Stunde"; + TodaySpecialAssignments.Add(new TodaySpecialAssignmentItem(periodLabel, sub.Description, sub.GroupLabel)); + } + } + + private List BuildTodaySupervisionItems(DateOnly today, List substitutionsToday) + { + var dutiesToday = _supervisionDuties.GetAll().Where(d => d.Weekday == today.DayOfWeek).ToList(); + var subsToday = substitutionsToday.Where(s => s.Kind == SubstitutionKind.Supervision).ToList(); + var afterPeriods = dutiesToday.Select(d => d.AfterPeriod) + .Concat(subsToday.Select(s => s.AfterPeriod!.Value)) + .Distinct().OrderBy(p => p); + + var result = new List(); + foreach (var afterPeriod in afterPeriods) + { + var sub = subsToday.FirstOrDefault(s => s.AfterPeriod == afterPeriod); + if (sub is not null) { result.Add(new TodaySupervisionItem(afterPeriod, sub.Description, isSubstitution: true)); continue; } + var duty = dutiesToday.First(d => d.AfterPeriod == afterPeriod); + result.Add(new TodaySupervisionItem(afterPeriod, duty.Location, isSubstitution: false)); + } + return result; } [RelayCommand] - private void OpenGroup(Guid groupId) => OnNavigateToGroup?.Invoke(groupId); + private void OpenGroup(Guid groupId) + { + if (groupId == Guid.Empty) return; // Vertretung in fremder Gruppe ohne echte GroupId + OnNavigateToGroup?.Invoke(groupId); + } [RelayCommand] private void ShowEditor() => ActiveTabIndex = 1; @@ -146,9 +218,11 @@ public partial class TimetableViewModel : ObservableObject /// parallele Kurs oder die nächste Stunde in der Woche auf einen Blick sichtbar sind. Die /// Badges (Ferien-Nähe, Klausur-Nähe) werden je Zelle am dort angezeigten Datum ausgerichtet, /// nicht am realen "heute" — sonst würde beim Blättern in andere Wochen ein Badge angezeigt, - /// das eigentlich zu einer ganz anderen Woche gehört. + /// das eigentlich zu einer ganz anderen Woche gehört. Aufsicht-Zeilen (wiederkehrend + einmalige + /// Vertretungsaufsicht) werden zwischen den betroffenen Stundenzeilen eingefügt, Vertretungsstunden + /// ersetzen für ihre Stunde die sonst aus dem Stundenplan abgeleitete Anzeige. /// - private void BuildWeekOverview(DateOnly today, HashSet publicHolidayDates) + private void BuildWeekOverview(DateOnly today, HashSet publicHolidayDates, List duties) { WeekItems.Clear(); var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7); @@ -160,22 +234,54 @@ public partial class TimetableViewModel : ObservableObject var groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id); var schoolHolidays = _schoolHolidays.GetAll(); var dateByWeekday = Weekdays.ToDictionary(w => w, w => monday.AddDays((int)w - (int)DayOfWeek.Monday)); + var dutiesByPeriod = duties.ToLookup(d => d.AfterPeriod); + var substitutionsThisWeek = dateByWeekday.Values.SelectMany(d => _substitutions.GetByDate(d)).ToList(); WeekItems.Add(WeekCellItem.Corner()); foreach (var weekday in Weekdays) WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today)); + AddWeekSupervisionRowIfAny(0, dutiesByPeriod, substitutionsThisWeek, dateByWeekday); + for (var period = FirstPeriod; period <= LastPeriod; period++) { WeekItems.Add(WeekCellItem.PeriodLabel(period)); foreach (var weekday in Weekdays) { + var date = dateByWeekday[weekday]; + var lessonSub = substitutionsThisWeek.FirstOrDefault(s => + s.Kind == SubstitutionKind.Lesson && s.Date == date && s.PeriodNumber == period); + if (lessonSub is not null) + { + WeekItems.Add(WeekCellItem.ForSubstitutionLesson(weekday, period, date == today, lessonSub)); + continue; + } + + var specialAssignment = substitutionsThisWeek.FirstOrDefault(s => + s.Kind == SubstitutionKind.SpecialAssignment && s.Date == date && + (s.IsAllDay || (period >= s.FromPeriod && period <= s.ToPeriod))); + if (specialAssignment is not null) + { + WeekItems.Add(WeekCellItem.ForSpecialAssignment(weekday, period, date == today, specialAssignment)); + continue; + } + var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period); if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; } - var date = dateByWeekday[weekday]; var group = groups.GetValueOrDefault(slot.GroupId); var subject = group?.SubjectId is { } subjectId ? _subjects.GetById(subjectId) : null; + + var cancelled = substitutionsThisWeek.FirstOrDefault(s => + s.Kind == SubstitutionKind.Cancelled && s.Date == date && s.PeriodNumber == period); + if (cancelled is not null) + { + WeekItems.Add(WeekCellItem.ForCancelled(weekday, period, date == today, cancelled, + subject?.ShortName is { Length: > 0 } csn ? csn : subject?.Name ?? "", + group?.Name ?? "?", slot.GroupId)); + continue; + } + var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault(); var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date); var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates); @@ -189,6 +295,30 @@ public partial class TimetableViewModel : ObservableObject colorHex, holidayBadge, hasExam, isLastBeforeExam, MentionsExperiment(lesson), slot.GroupId, isHoliday)); } + AddWeekSupervisionRowIfAny(period, dutiesByPeriod, substitutionsThisWeek, dateByWeekday); + } + } + + private void AddWeekSupervisionRowIfAny(int afterPeriod, ILookup dutiesByPeriod, + List substitutionsThisWeek, Dictionary dateByWeekday) + { + var hasAny = dutiesByPeriod.Contains(afterPeriod) || + substitutionsThisWeek.Any(s => s.Kind == SubstitutionKind.Supervision && s.AfterPeriod == afterPeriod); + if (!hasAny) return; + + WeekItems.Add(WeekCellItem.SupervisionRowLabel(afterPeriod)); + foreach (var weekday in Weekdays) + { + var date = dateByWeekday[weekday]; + var substitution = substitutionsThisWeek.FirstOrDefault(s => + s.Kind == SubstitutionKind.Supervision && s.Date == date && s.AfterPeriod == afterPeriod); + if (substitution is not null) + { + WeekItems.Add(WeekCellItem.SupervisionCell(substitution.Description, isSubstitution: true)); + continue; + } + var duty = dutiesByPeriod[afterPeriod].FirstOrDefault(d => d.Weekday == weekday); + WeekItems.Add(WeekCellItem.SupervisionCell(duty?.Location ?? "", isSubstitution: false)); } } @@ -226,16 +356,19 @@ public partial class TimetableViewModel : ObservableObject // ── Bearbeiten-Raster ───────────────────────────────────────────────────── - private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges) + private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges, List duties) { Cells.Clear(); var allSlots = _slots.GetAll(); var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name); + var dutiesByPeriod = duties.ToLookup(d => d.AfterPeriod); Cells.Add(TimetableCellItem.Corner()); foreach (var weekday in Weekdays) Cells.Add(TimetableCellItem.WeekdayHeader(weekday)); + AddGridSupervisionRowIfAny(0, dutiesByPeriod); + for (var period = FirstPeriod; period <= LastPeriod; period++) { Cells.Add(TimetableCellItem.PeriodLabel(period)); @@ -246,6 +379,21 @@ public partial class TimetableViewModel : ObservableObject var badge = slot is not null ? badges.GetValueOrDefault((weekday, slot.GroupId), "") : ""; Cells.Add(TimetableCellItem.ForSlot(weekday, period, slot, groupName, ColorFor(groupName), badge)); } + AddGridSupervisionRowIfAny(period, dutiesByPeriod); + } + } + + /// Aufsicht wird nur in den Einstellungen gepflegt (siehe SettingsView) — im + /// Bearbeiten-Raster ist die Zeile bewusst nur Anzeige, kein weiterer Klick-Dialog nötig für + /// eine so kleine, seltene Pflegeaufgabe. + private void AddGridSupervisionRowIfAny(int afterPeriod, ILookup dutiesByPeriod) + { + if (!dutiesByPeriod.Contains(afterPeriod)) return; + Cells.Add(TimetableCellItem.SupervisionRowLabel(afterPeriod)); + foreach (var weekday in Weekdays) + { + var duty = dutiesByPeriod[afterPeriod].FirstOrDefault(d => d.Weekday == weekday); + Cells.Add(TimetableCellItem.SupervisionCell(duty?.Location ?? "")); } } @@ -345,6 +493,8 @@ public class TimetableCellItem { public bool IsHeader { get; private init; } public bool IsPeriodLabel { get; private init; } + public bool IsSupervisionRow { get; private init; } + public bool IsSupervisionCell { get; private init; } public string Text { get; private init; } = ""; public DayOfWeek? Weekday { get; private init; } public int PeriodNumber { get; private init; } @@ -352,9 +502,11 @@ public class TimetableCellItem public string GroupName { get; private init; } = ""; public string ColorHex { get; private init; } = "#9E9E9E"; public string BadgeText { get; private init; } = ""; + public string SupervisionLocation { get; private init; } = ""; + public bool HasSupervision => SupervisionLocation.Length > 0; public bool HasBadge => BadgeText.Length > 0; public bool IsAssigned => Slot is not null; - public bool IsSlotCell => !IsHeader && !IsPeriodLabel; + public bool IsSlotCell => !IsHeader && !IsPeriodLabel && !IsSupervisionRow && !IsSupervisionCell; public static TimetableCellItem Corner() => new() { IsHeader = true, Text = "" }; @@ -370,6 +522,18 @@ public class TimetableCellItem public static TimetableCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() }; + public static TimetableCellItem SupervisionRowLabel(int afterPeriod) => new() + { + IsSupervisionRow = true, + Text = afterPeriod == 0 ? "Aufsicht (vor 1.)" : $"Aufsicht (n. {afterPeriod}.)", + }; + + public static TimetableCellItem SupervisionCell(string location) => new() + { + IsSupervisionCell = true, + SupervisionLocation = location, + }; + public static TimetableCellItem ForSlot(DayOfWeek day, int period, TimetableSlot? slot, string groupName, string colorHex, string badgeText) => new() { @@ -384,8 +548,14 @@ public class WeekCellItem { public bool IsHeader { get; private init; } public bool IsPeriodLabel { get; private init; } - public bool IsSlotCell => !IsHeader && !IsPeriodLabel; + public bool IsSupervisionRow { get; private init; } + public bool IsSupervisionCell { get; private init; } + public bool IsSlotCell => !IsHeader && !IsPeriodLabel && !IsSupervisionRow && !IsSupervisionCell; public bool IsAssigned { get; private init; } + public bool IsSubstitutionLesson { get; private init; } + public bool IsSubstitutionSupervision { get; private init; } + public bool IsSpecialAssignment { get; private init; } + public bool IsCancelled { get; private init; } public string Text { get; private init; } = ""; public DayOfWeek? Weekday { get; private init; } public int PeriodNumber { get; private init; } @@ -402,6 +572,8 @@ public class WeekCellItem public bool IsLastBeforeExam { get; private init; } public bool HasExperiment { get; private init; } public bool IsHoliday { get; private init; } + public string SupervisionLocation { get; private init; } = ""; + public bool HasSupervision => SupervisionLocation.Length > 0; public bool HasRoom => !string.IsNullOrWhiteSpace(Room); public bool HasTopic => !string.IsNullOrWhiteSpace(Topic); @@ -422,6 +594,19 @@ public class WeekCellItem public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period }; + public static WeekCellItem SupervisionRowLabel(int afterPeriod) => new() + { + IsSupervisionRow = true, + Text = afterPeriod == 0 ? "Aufsicht (vor 1.)" : $"Aufsicht (n. {afterPeriod}.)", + }; + + public static WeekCellItem SupervisionCell(string location, bool isSubstitution) => new() + { + IsSupervisionCell = true, + SupervisionLocation = location, + IsSubstitutionSupervision = isSubstitution, + }; + 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) => new() @@ -432,6 +617,30 @@ public class WeekCellItem IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId, IsHoliday = isHoliday, }; + + public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new() + { + Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true, + SubjectLabel = "Vertretung", GroupName = entry.GroupLabel, Topic = entry.Description, + ColorHex = "#8E24AA", GroupId = entry.GroupId ?? Guid.Empty, + }; + + public static WeekCellItem ForSpecialAssignment(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new() + { + Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSpecialAssignment = true, + SubjectLabel = "Sondereinsatz", + GroupName = string.IsNullOrWhiteSpace(entry.GroupLabel) ? "" : entry.GroupLabel, + Topic = entry.Description, + ColorHex = "#00838F", GroupId = entry.GroupId ?? Guid.Empty, + }; + + public static WeekCellItem ForCancelled(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry, + string subjectLabel, string groupName, Guid groupId) => new() + { + Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsCancelled = true, + SubjectLabel = subjectLabel, GroupName = groupName, Topic = entry.Description, + ColorHex = "#757575", GroupId = groupId, + }; } public class HoursWarningItem(string groupName, int assigned, int expected) @@ -440,17 +649,53 @@ public class HoursWarningItem(string groupName, int assigned, int expected) public string Text { get; } = $"{groupName}: {assigned} von {expected} Wochenstunden eingetragen"; } -public class TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room, - string colorHex, string? lessonTopic, string? examTitle) +public class TodayLessonItem { - public Guid GroupId { get; } = groupId; - public int PeriodNumber { get; } = periodNumber; - public string GroupName { get; } = groupName; - public string Room { get; } = room; - public string ColorHex { get; } = colorHex; - public string? LessonTopic { get; } = lessonTopic; - public string? ExamTitle { get; } = examTitle; + public Guid GroupId { get; private init; } + public int PeriodNumber { get; private init; } + public string GroupName { get; private init; } = ""; + public string Room { get; private init; } = ""; + public string ColorHex { get; private init; } = "#9E9E9E"; + public string? LessonTopic { get; private init; } + public string? ExamTitle { get; private init; } + public bool IsSubstitution { get; private init; } + public bool IsCancelled { get; private init; } public bool HasRoom => !string.IsNullOrWhiteSpace(Room); public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic); public bool HasExam => ExamTitle is not null; + public bool HasGroupId => GroupId != Guid.Empty; + + public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room, + string colorHex, string? lessonTopic, string? examTitle) + { + GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room; + ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle; + } + + public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new( + entry.GroupId ?? Guid.Empty, 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", + string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null) + { IsCancelled = true }; +} + +public class TodaySupervisionItem(int afterPeriod, string description, bool isSubstitution) +{ + public int AfterPeriod { get; } = afterPeriod; + public string PeriodLabel { get; } = afterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {afterPeriod}. Stunde"; + public string Description { get; } = description; + public bool IsSubstitution { get; } = isSubstitution; +} + +public class TodaySpecialAssignmentItem(string periodLabel, string description, string groupLabel) +{ + public string PeriodLabel { get; } = periodLabel; + public string Description { get; } = description; + public string GroupLabel { get; } = groupLabel; + public bool HasGroupLabel => !string.IsNullOrWhiteSpace(GroupLabel); + public string DisplayText { get; } = + $"{periodLabel} — {description}" + (string.IsNullOrWhiteSpace(groupLabel) ? "" : $" ({groupLabel})"); } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 03b2f4f..5ec23f9 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -127,10 +127,29 @@ public partial class SettingsViewModel : ObservableObject public List StateOptions { get; } = GermanStateDisplay.Options.ToList(); public ObservableCollection SchoolHolidayEntries { get; } = []; + // ── Stundenraster: Uhrzeiten je Einzelstunde (4.2.2 Nachtrag) ──────────── + + [ObservableProperty] private string _periodTimesError = ""; + [ObservableProperty] private string _periodTimesStatus = ""; + + public ObservableCollection PeriodTimes { get; } = []; + + // ── Aufsichten: wiederkehrende Pausenaufsicht (4.3 Nachtrag) ───────────── + + [ObservableProperty] private string _newDutyWeekdayName = WeekdayDisplay.Options[0]; + [ObservableProperty] private int _newDutyAfterPeriod; + [ObservableProperty] private string _newDutyLocation = ""; + [ObservableProperty] private string _newDutyError = ""; + + public string[] WeekdayOptions { get; } = WeekdayDisplay.Options; + public ObservableCollection SupervisionDuties { get; } = []; + // ── Konstruktor ─────────────────────────────────────────────────────────── private readonly ISchoolHolidayRepository _schoolHolidays; private readonly SchoolCalendarSettingsService _calendarSettings; + private readonly PeriodScheduleService _periodSchedule; + private readonly ISupervisionDutyRepository _supervisionDuties; public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, @@ -138,7 +157,8 @@ public partial class SettingsViewModel : ObservableObject AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy, IDocumentationRepository documentation, IStudentRepository students, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, - SchoolCalendarSettingsService calendarSettings) + SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, + ISupervisionDutyRepository supervisionDuties) { _subjects = subjects; _domainRepo = domainRepo; @@ -155,6 +175,8 @@ public partial class SettingsViewModel : ObservableObject _shorthandCodes = shorthandCodes; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; + _periodSchedule = periodSchedule; + _supervisionDuties = supervisionDuties; LoadSubjects(); LoadShorthandCodes(); LoadGradingKeyTemplates(); @@ -167,6 +189,84 @@ public partial class SettingsViewModel : ObservableObject LoadExpiredDocuments(); SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State); LoadSchoolHolidays(); + LoadPeriodTimes(); + LoadSupervisionDuties(); + } + + // ── Aufsichten: Laden / Hinzufügen / Löschen ───────────────────────────── + + private void LoadSupervisionDuties() + { + SupervisionDuties.Clear(); + foreach (var d in _supervisionDuties.GetAll()) + SupervisionDuties.Add(new SupervisionDutyItem(d)); + } + + [RelayCommand] + private void AddSupervisionDuty() + { + NewDutyError = ""; + if (string.IsNullOrWhiteSpace(NewDutyLocation)) { NewDutyError = "Ort/Bezeichnung erforderlich."; return; } + if (NewDutyAfterPeriod is < 0 or > 10) { NewDutyError = "Muss zwischen 0 und 10 liegen."; return; } + + try + { + _supervisionDuties.Save(new SupervisionDuty + { + Weekday = WeekdayDisplay.FromLabel(NewDutyWeekdayName), + AfterPeriod = NewDutyAfterPeriod, + Location = NewDutyLocation.Trim(), + }); + } + catch (InvalidOperationException ex) { NewDutyError = ex.Message; return; } + + NewDutyLocation = ""; NewDutyAfterPeriod = 0; + LoadSupervisionDuties(); + } + + [RelayCommand] + private void RemoveSupervisionDuty(SupervisionDutyItem? item) + { + if (item is null) return; + _supervisionDuties.Delete(item.Id); + SupervisionDuties.Remove(item); + } + + // ── Stundenraster: Laden / Speichern ───────────────────────────────────── + + private void LoadPeriodTimes() + { + PeriodTimes.Clear(); + for (var period = 1; period <= 10; period++) + { + var times = _periodSchedule.GetTimes(period); + PeriodTimes.Add(new PeriodTimeEditItem(period, times?.Start, times?.End)); + } + } + + [RelayCommand] + private void SavePeriodTimes() + { + PeriodTimesError = ""; PeriodTimesStatus = ""; + var entries = new List(); + + foreach (var item in PeriodTimes) + { + if (string.IsNullOrWhiteSpace(item.StartText) && string.IsNullOrWhiteSpace(item.EndText)) + continue; // Stunde bewusst nicht konfiguriert — ok, keine Pflicht für alle 10. + + if (!TimeOnly.TryParseExact(item.StartText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) || + !TimeOnly.TryParseExact(item.EndText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end)) + { PeriodTimesError = $"{item.PeriodLabel}: Format HH:MM."; return; } + + if (end <= start) + { PeriodTimesError = $"{item.PeriodLabel}: Ende muss nach dem Beginn liegen."; return; } + + entries.Add(new PeriodTimeEntry { PeriodNumber = item.PeriodNumber, Start = start, End = end }); + } + + _periodSchedule.SetPeriods(entries); + PeriodTimesStatus = "Gespeichert."; } // ── Ferien & Feiertage: Bundesland / Schulferien pflegen ───────────────── @@ -842,6 +942,47 @@ public class SchoolHolidayItem(SchoolHoliday h) public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} – {h.EndDate:dd.MM.yyyy}"; } +public partial class PeriodTimeEditItem : ObservableObject +{ + public int PeriodNumber { get; } + public string PeriodLabel { get; } + + [ObservableProperty] private string _startText; + [ObservableProperty] private string _endText; + + public PeriodTimeEditItem(int periodNumber, TimeOnly? start, TimeOnly? end) + { + PeriodNumber = periodNumber; + PeriodLabel = $"{periodNumber}. Stunde"; + _startText = start?.ToString("HH:mm") ?? ""; + _endText = end?.ToString("HH:mm") ?? ""; + } +} + +// ── Wochentag: deutsche Anzeige (Mo–Fr, für Aufsichten) ──────────────────────── + +public static class WeekdayDisplay +{ + private static readonly (DayOfWeek Day, string Name)[] Entries = + [ + (DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"), + (DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"), + ]; + + public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray(); + public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name; + public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day; +} + +public class SupervisionDutyItem(SupervisionDuty d) +{ + public Guid Id { get; } = d.Id; + public string WeekdayLabel { get; } = WeekdayDisplay.Label(d.Weekday); + public int AfterPeriod { get; } = d.AfterPeriod; + public string PeriodLabel { get; } = d.AfterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {d.AfterPeriod}. Stunde"; + public string Location { get; } = d.Location; +} + // ── JSON DTOs ───────────────────────────────────────────────────────────────── internal class CatalogDto diff --git a/LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml b/LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml new file mode 100644 index 0000000..2336181 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + +