diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 38f78a2..b305ad2 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -85,6 +85,19 @@ public interface ILessonRepository void Save(Lesson lesson); void Delete(Guid id); } +public interface ITimetableSlotRepository +{ + List GetAll(); + List GetByGroup(Guid groupId); + void Save(TimetableSlot slot); + void Delete(Guid id); +} +public interface ISchoolHolidayRepository +{ + List GetAll(); + void Save(SchoolHoliday holiday); + 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 e247859..ba8f829 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -110,6 +110,39 @@ public class AlternativeLessonPath public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } +/// +/// Ein fester Termin im wöchentlichen Stundenplan (4.3): eine Lerngruppe trifft sich an einem +/// Wochentag zu einer bestimmten Stunde. Wiederkehrendes Muster, kein konkretes Datum — für +/// tatsächlich gehaltene Einzelstunden siehe . Pro Wochentag/Stunde ist +/// höchstens eine Gruppe eingetragen (ein Lehrer kann nicht gleichzeitig an zwei Orten sein). +/// +public class TimetableSlot +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GroupId { get; set; } + public DayOfWeek Weekday { get; set; } + public int PeriodNumber { get; set; } + public string? Room { get; set; } +} + +/// +/// Unterrichtsfreier Zeitraum (4.3.5): Schulferien werden manuell gepflegt (nicht algorithmisch +/// herleitbar, jährlich neu von den Bundesländern festgelegt). Gesetzliche Feiertage werden +/// dagegen berechnet (PublicHolidayService) und nicht in der Datenbank gespeichert. +/// +public class SchoolHoliday +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = ""; + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } +} + +public enum GermanState +{ + BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH +} + /// /// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr). /// ist das zuletzt berechnete Ergebnis; diff --git a/LehrerApp.Core/Services/PublicHolidayService.cs b/LehrerApp.Core/Services/PublicHolidayService.cs new file mode 100644 index 0000000..d9e0e91 --- /dev/null +++ b/LehrerApp.Core/Services/PublicHolidayService.cs @@ -0,0 +1,79 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +public record PublicHoliday(DateOnly Date, string Name); + +/// +/// Berechnet die gesetzlichen Feiertage eines Bundeslands für ein Kalenderjahr (4.3.5). +/// Im Gegensatz zu Schulferien sind Feiertage über eine feste Regel je Bundesland herleitbar und +/// werden deshalb nicht in der Datenbank gespeichert. +/// +public class PublicHolidayService +{ + public List GetHolidays(int year, GermanState state) + { + var easterSunday = EasterSunday(year); + var holidays = new List + { + new(new DateOnly(year, 1, 1), "Neujahr"), + new(easterSunday.AddDays(-2), "Karfreitag"), + new(easterSunday.AddDays(1), "Ostermontag"), + new(new DateOnly(year, 5, 1), "Tag der Arbeit"), + new(easterSunday.AddDays(39), "Christi Himmelfahrt"), + new(easterSunday.AddDays(50), "Pfingstmontag"), + new(new DateOnly(year, 10, 3), "Tag der Deutschen Einheit"), + new(new DateOnly(year, 12, 25), "1. Weihnachtstag"), + new(new DateOnly(year, 12, 26), "2. Weihnachtstag"), + }; + + if (state is GermanState.BW or GermanState.BY or GermanState.ST) + holidays.Add(new(new DateOnly(year, 1, 6), "Heilige Drei Könige")); + + if (state is GermanState.BE) + holidays.Add(new(new DateOnly(year, 3, 8), "Internationaler Frauentag")); + + if (state is GermanState.BW or GermanState.BY or GermanState.HE or GermanState.NW + or GermanState.RP or GermanState.SL) + holidays.Add(new(easterSunday.AddDays(60), "Fronleichnam")); + + if (state is GermanState.SL) + holidays.Add(new(new DateOnly(year, 8, 15), "Mariä Himmelfahrt")); + + if (state is GermanState.BB or GermanState.MV or GermanState.SN or GermanState.ST + or GermanState.TH or GermanState.HB or GermanState.HH or GermanState.NI + or GermanState.SH) + holidays.Add(new(new DateOnly(year, 10, 31), "Reformationstag")); + + if (state is GermanState.BW or GermanState.BY or GermanState.NW or GermanState.RP + or GermanState.SL) + holidays.Add(new(new DateOnly(year, 11, 1), "Allerheiligen")); + + if (state is GermanState.SN) + holidays.Add(new(BussUndBettag(year), "Buß- und Bettag")); + + return holidays.OrderBy(h => h.Date).ToList(); + } + + /// Gauß'sche Osterformel. + private static DateOnly EasterSunday(int year) + { + int a = year % 19, b = year / 100, c = year % 100; + int d = b / 4, e = b % 4, f = (b + 8) / 25, g = (b - f + 1) / 3; + int h = (19 * a + b - d - g + 15) % 30; + int i = c / 4, k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7; + int m = (a + 11 * h + 22 * l) / 451; + int month = (h + l - 7 * m + 114) / 31; + int day = (h + l - 7 * m + 114) % 31 + 1; + return new DateOnly(year, month, day); + } + + /// Buß- und Bettag: Mittwoch vor dem 23. November (entspricht dem letzten Mittwoch vor dem + /// ersten Advent). + private static DateOnly BussUndBettag(int year) + { + var date = new DateOnly(year, 11, 23); + while (date.DayOfWeek != DayOfWeek.Wednesday) date = date.AddDays(-1); + return date; + } +} diff --git a/LehrerApp.Core/Services/SchoolCalendarSettingsService.cs b/LehrerApp.Core/Services/SchoolCalendarSettingsService.cs new file mode 100644 index 0000000..bbfe0fb --- /dev/null +++ b/LehrerApp.Core/Services/SchoolCalendarSettingsService.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +internal class SchoolCalendarConfig +{ + public GermanState State { get; set; } = GermanState.NW; +} + +/// Welches Bundesland für die Feiertagsberechnung (4.3.5) gilt. +public class SchoolCalendarSettingsService +{ + private readonly string _configPath; + private SchoolCalendarConfig _config; + + public GermanState State => _config.State; + + public SchoolCalendarSettingsService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "schoolcalendar.json"); + _config = Load(); + } + + public void SetState(GermanState state) + { + _config.State = state; + File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); + } + + private SchoolCalendarConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new SchoolCalendarConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwert */ } + return new SchoolCalendarConfig(); + } +} diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index c049415..036d923 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -529,4 +529,63 @@ public sealed class RepositoryTests Assert.Empty(repo.GetAll()); } + + // ── TimetableSlotRepository ─────────────────────────────────────────────── + + [Fact] + public void TimetableSlotRepository_GetByGroup_FindetNurSlotsDerGruppe() + { + using var db = NewInMemoryContext(); + var repo = new TimetableSlotRepository(db); + var groupA = Guid.NewGuid(); + var groupB = Guid.NewGuid(); + repo.Save(new TimetableSlot { GroupId = groupA, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + repo.Save(new TimetableSlot { GroupId = groupB, Weekday = DayOfWeek.Monday, PeriodNumber = 2 }); + + var result = repo.GetByGroup(groupA); + + Assert.Single(result); + Assert.Equal(groupA, result[0].GroupId); + } + + [Fact] + public void TimetableSlotRepository_Save_LehntDoppelbelegungDerselbenStundeAb() + { + using var db = NewInMemoryContext(); + var repo = new TimetableSlotRepository(db); + repo.Save(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }); + + Assert.Throws(() => + repo.Save(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 })); + } + + [Fact] + public void TimetableSlotRepository_Save_AktualisierenDesselbenSlotsIstErlaubt() + { + using var db = NewInMemoryContext(); + var repo = new TimetableSlotRepository(db); + var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }; + repo.Save(slot); + + slot.Room = "R204"; + repo.Save(slot); + + Assert.Equal("R204", repo.GetAll().Single().Room); + } + + // ── SchoolHolidayRepository ─────────────────────────────────────────────── + + [Fact] + public void SchoolHolidayRepository_GetAll_SortiertNachStartdatum() + { + using var db = NewInMemoryContext(); + var repo = new SchoolHolidayRepository(db); + repo.Save(new SchoolHoliday { Name = "Sommerferien", StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 8, 10) }); + repo.Save(new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) }); + + var result = repo.GetAll(); + + Assert.Equal("Osterferien", result[0].Name); + Assert.Equal("Sommerferien", result[1].Name); + } } diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 1aa8ffe..1f391db 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -57,6 +57,8 @@ public class LiteDbContext : IDisposable public ILiteCollection CompetencyDomains => _db.GetCollection("competency_domains"); public ILiteCollection ShorthandCodes => _db.GetCollection("shorthand_codes"); public ILiteCollection AlternativeLessonPaths => _db.GetCollection("alternative_lesson_paths"); + public ILiteCollection TimetableSlots => _db.GetCollection("timetable_slots"); + public ILiteCollection SchoolHolidays => _db.GetCollection("school_holidays"); public void Checkpoint() => _db.Checkpoint(); @@ -369,6 +371,9 @@ public class LiteDbContext : IDisposable CompetencyDomains.EnsureIndex(x => x.GradeLevel); ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true); AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); + TimetableSlots.EnsureIndex(x => x.GroupId); + TimetableSlots.EnsureIndex("ux_timetable_weekday_period", + BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true); } public void Dispose() => _db.Dispose(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index d68c5dd..2930bf1 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -386,6 +386,30 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes public void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id); } +public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository +{ + public List GetAll() => + db.TimetableSlots.FindAll().OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList(); + public List GetByGroup(Guid groupId) => + db.TimetableSlots.Find(s => s.GroupId == groupId).OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList(); + public void Save(TimetableSlot slot) + { + var occupied = db.TimetableSlots.FindAll() + .FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber); + if (occupied is not null && occupied.Id != slot.Id) + throw new InvalidOperationException("Diese Stunde ist bereits belegt."); + db.TimetableSlots.Upsert(slot); + } + public void Delete(Guid id) => db.TimetableSlots.Delete(id); +} + +public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository +{ + public List GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList(); + public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday); + public void Delete(Guid id) => db.SchoolHolidays.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 d810ef3..a2b82b5 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -98,6 +98,17 @@ public class FakeResults : IExamResultRepository public void SaveMany(List results) { } } +public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository +{ + private readonly List _all = []; + public List GetAll() => _all; + public List GetByGradingSystem(GradingSystem system) => + _all.Where(t => t.GradingSystem == system).ToList(); + public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id); + public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); } + public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id); +} + public class FakeSchemes : IGradingSchemeRepository { private readonly Dictionary _byGroup = []; @@ -206,6 +217,32 @@ public class FakeAlternativeLessonPaths(List all) : IAlte public void Delete(Guid id) => all.RemoveAll(p => p.Id == id); } +public class FakeTimetableSlots : ITimetableSlotRepository +{ + private readonly List _all = []; + public void Add(TimetableSlot s) => _all.Add(s); + public List GetAll() => _all.ToList(); + public List GetByGroup(Guid groupId) => _all.Where(s => s.GroupId == groupId).ToList(); + public void Save(TimetableSlot slot) + { + var occupied = _all.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber); + if (occupied is not null && occupied.Id != slot.Id) + throw new InvalidOperationException("Diese Stunde ist bereits belegt."); + _all.RemoveAll(s => s.Id == slot.Id); + _all.Add(slot); + } + public void Delete(Guid id) => _all.RemoveAll(s => s.Id == id); +} + +public class FakeSchoolHolidays : ISchoolHolidayRepository +{ + private readonly List _all = []; + public void Add(SchoolHoliday h) => _all.Add(h); + public List GetAll() => _all.ToList(); + public void Save(SchoolHoliday holiday) { _all.RemoveAll(h => h.Id == holiday.Id); _all.Add(holiday); } + public void Delete(Guid id) => _all.RemoveAll(h => h.Id == id); +} + public class FakeReportGrades : IReportGradeRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs new file mode 100644 index 0000000..bbdbaf6 --- /dev/null +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -0,0 +1,106 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Data; +using LehrerApp.Desktop.ViewModels.Settings; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class SettingsViewModelTests +{ + private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null) + { + // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, + // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + + return 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([]), + holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath)); + } + + [Fact] + public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt() + { + var holidays = new FakeSchoolHolidays(); + var vm = BuildViewModel(holidays); + vm.NewHolidayName = "Sommerferien"; + vm.NewHolidayStartText = "01.07.2026"; + vm.NewHolidayEndText = "10.08.2026"; + + vm.AddSchoolHolidayCommand.Execute(null); + + Assert.Single(holidays.GetAll()); + Assert.Single(vm.SchoolHolidayEntries); + } + + [Fact] + public void AddSchoolHoliday_EndeVorBeginn_SetztFehlerUndSpeichertNicht() + { + var holidays = new FakeSchoolHolidays(); + var vm = BuildViewModel(holidays); + vm.NewHolidayName = "Ungültig"; + vm.NewHolidayStartText = "10.08.2026"; + vm.NewHolidayEndText = "01.07.2026"; + + vm.AddSchoolHolidayCommand.Execute(null); + + Assert.Empty(holidays.GetAll()); + Assert.NotEqual("", vm.HolidayDateError); + } + + [Fact] + public void AddSchoolHoliday_FehlenderName_SetztFehlerUndSpeichertNicht() + { + var holidays = new FakeSchoolHolidays(); + var vm = BuildViewModel(holidays); + vm.NewHolidayStartText = "01.07.2026"; + vm.NewHolidayEndText = "10.08.2026"; + + vm.AddSchoolHolidayCommand.Execute(null); + + Assert.Empty(holidays.GetAll()); + Assert.NotEqual("", vm.HolidayNameError); + } + + [Fact] + public void RemoveSchoolHoliday_EntferntEintragAusRepositoryUndListe() + { + var holidays = new FakeSchoolHolidays(); + var holiday = new SchoolHoliday { Name = "Herbstferien", StartDate = new DateOnly(2026, 10, 12), EndDate = new DateOnly(2026, 10, 24) }; + holidays.Add(holiday); + var vm = BuildViewModel(holidays); + + vm.RemoveSchoolHolidayCommand.Execute(vm.SchoolHolidayEntries[0]); + + Assert.Empty(holidays.GetAll()); + Assert.Empty(vm.SchoolHolidayEntries); + } + + [Fact] + public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings() + { + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-state-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + var calendarSettings = new SchoolCalendarSettingsService(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(), calendarSettings); + + vm.SelectedStateName = "Bayern"; + + Assert.Equal(GermanState.BY, calendarSettings.State); + } +} diff --git a/LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs new file mode 100644 index 0000000..64f709b --- /dev/null +++ b/LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs @@ -0,0 +1,78 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Planning; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class TimetableSlotDialogViewModelTests +{ + private static SchoolYearService SchoolYear() => new(); + + [Fact] + public void Save_OhneAusgewaehlteGruppe_SetztFehlerUndSpeichertNicht() + { + var slots = new FakeTimetableSlots(); + var groups = new FakeGroups([]); + var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null); + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.GroupError); + } + + [Fact] + public void Save_MitAusgewaehlterGruppe_LegtNeuenSlotAn() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + var groups = new FakeGroups([group]); + var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null) + { + SelectedGroupName = "Q1 Chemie", Room = "R204", + }; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal(group.Id, vm.Result!.GroupId); + Assert.Equal("R204", vm.Result.Room); + Assert.Single(slots.GetAll()); + } + + [Fact] + public void Save_BelegteStunde_ZeigtFreundlicheFehlermeldungStattAbsturz() + { + var groupA = new LearningGroup { Name = "Q1 Chemie" }; + var groupB = new LearningGroup { Name = "Q1 Physik" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupA.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var groups = new FakeGroups([groupA, groupB]); + var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null) + { + SelectedGroupName = "Q1 Physik", + }; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.Equal("Diese Stunde ist bereits belegt.", vm.GroupError); + } + + [Fact] + public void Delete_BeimBearbeitenEinesVorhandenenSlots_EntferntIhn() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slot = new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }; + var slots = new FakeTimetableSlots(); + slots.Add(slot); + var groups = new FakeGroups([group]); + var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, slot); + + vm.DeleteCommand.Execute(null); + + Assert.True(vm.Deleted); + Assert.Empty(slots.GetAll()); + } +} diff --git a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs new file mode 100644 index 0000000..eefef35 --- /dev/null +++ b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs @@ -0,0 +1,413 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Planning; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +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) + { + // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf + // (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new TimetableViewModel( + slots, groups, subjects ?? new FakeSubjects([]), + lessons ?? new FakeLessons(), exams ?? new FakeExams([]), + holidays ?? new FakeSchoolHolidays(), + calendarSettings ?? new SchoolCalendarSettingsService(tempPath), + new PublicHolidayService(), new SchoolYearService()); + } + + /// Nächstes Datum ab (inkl.) , das auf einen Wochentag Mo-Fr fällt — + /// nur diese Tage haben eine Spalte im Raster. Für Badge-Tests reicht ein beliebiger + /// Mo-Fr-Wochentag: die Badges hängen nur am Wochentag (nicht am konkreten Kalenderdatum), + /// da `TimetableSlot` ein wiederkehrendes Muster ohne Datum ist. + private static DateOnly NextGridWeekday(DateOnly from) + { + var d = from; + while (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) d = d.AddDays(1); + return d; + } + + /// Datum des angegebenen Wochentags in der laufenden Kalenderwoche — exakt wie + /// `TimetableViewModel.BuildWeekOverview` es berechnet, für Tests der Wochenkachel-Inhalte + /// (Thema, Klausur am Tag), die anders als die Badges an ein konkretes Datum gebunden sind. + private static DateOnly DateInCurrentWeek(DayOfWeek weekday) + { + var today = DateOnly.FromDateTime(DateTime.Today); + var monday = today.AddDays(-((int)today.DayOfWeek + 6) % 7); + return monday.AddDays((int)weekday - (int)DayOfWeek.Monday); + } + + [Fact] + public void Load_BautEineZelleProWochentagUndStundePlusKopfzeilen() + { + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); + + // 1 Ecke + 5 Wochentage + 10 Stunden × (1 Label + 5 Zellen) + Assert.Equal(6 + 10 * 6, vm.Cells.Count); + Assert.Equal(6 + 10 * 6, vm.WeekItems.Count); + } + + [Fact] + public void Load_ZeigtZugewieseneGruppeInDerPassendenZelle() + { + var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today)); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1, Room = "R204" }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1); + + Assert.True(cell.IsAssigned); + Assert.Equal("Q1 Chemie · R204", cell.Text); + } + + [Fact] + public void HoursWarnings_WeichtDieZugewieseneStundenzahlAb_ZeigtWarnung() + { + var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear(), HoursPerWeek = 3 }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + Assert.Single(vm.HoursWarnings); + Assert.Contains("1 von 3", vm.HoursWarnings[0].Text); + } + + [Fact] + public void HoursWarnings_PassendeStundenzahl_KeineWarnung() + { + var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear(), HoursPerWeek = 1 }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + Assert.Empty(vm.HoursWarnings); + } + + // ── "Heute"-Ansicht: Tagesliste (Nutzer-Feedback: nicht-editierende Standardansicht) ──── + + [Fact] + public void Load_ZeigtHeutigeStundeMitGruppeUndRaum() + { + 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 = 3, Room = "R204" }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + var item = Assert.Single(vm.TodayItems); + Assert.Equal("Q1 Chemie", item.GroupName); + Assert.Equal("R204", item.Room); + Assert.Equal(3, item.PeriodNumber); + } + + [Fact] + public void Load_HeutigeStundeMitLektionUndKlausur_ZeigtBeides() + { + 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 lessons = new FakeLessons(); + lessons.Add(new Lesson { GroupId = group.Id, Date = today, Topic = "Redoxreaktionen" }); + var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today, Title = "Klausur Nr. 2" }]); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, exams: exams); + + var item = Assert.Single(vm.TodayItems); + Assert.Equal("Redoxreaktionen", item.LessonTopic); + Assert.Equal("Klausur Nr. 2", item.ExamTitle); + Assert.True(item.HasExam); + } + + [Fact] + public void OpenGroup_RuftOnNavigateToGroupMitDerGroupIdAuf() + { + 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 vm = BuildViewModel(slots, new FakeGroups([group])); + Guid? navigatedTo = null; + vm.OnNavigateToGroup = id => navigatedTo = id; + + vm.OpenGroupCommand.Execute(vm.TodayItems[0].GroupId); + + Assert.Equal(group.Id, navigatedTo); + } + + [Fact] + public void ShowEditor_SchaltetAufBearbeiten_Tab() + { + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); + + vm.ShowEditorCommand.Execute(null); + + Assert.Equal(1, vm.ActiveTabIndex); + } + + // ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ──────────────────── + + [Fact] + public void Load_Wochenkachel_ZeigtFachKlasseRaumUndThema() + { + var subject = new Subject { Name = "Chemie", ShortName = "Ch" }; + var group = new LearningGroup { Name = "Q1 Chemie", SubjectId = subject.Id }; + var date = DateInCurrentWeek(DayOfWeek.Monday); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1, Room = "R204" }); + var lessons = new FakeLessons(); + lessons.Add(new Lesson { GroupId = group.Id, Date = date, Topic = "Redoxreaktionen" }); + var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), lessons: lessons); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.Equal("Ch", cell.SubjectLabel); + Assert.Equal("Q1 Chemie", cell.GroupName); + Assert.Equal("R204", cell.Room); + Assert.Equal("Redoxreaktionen", cell.Topic); + } + + [Fact] + public void Load_Wochenkachel_UnbelegteZelle_IstNichtZugewiesen() + { + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.False(cell.IsAssigned); + } + + [Fact] + public void Load_Wochenkachel_KlausurAmTag_ZeigtKlausurIcon() + { + 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 exams = new FakeExams([new Exam { GroupId = group.Id, Date = date, Title = "Klausur" }]); + var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.True(cell.HasExam); + } + + [Fact] + public void Load_Wochenkachel_TagInSchulferien_WirdAusgegraut() + { + 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 holidays = new FakeSchoolHolidays(); + holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = date, EndDate = date.AddDays(5) }); + var vm = BuildViewModel(slots, new FakeGroups([group]), holidays); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.True(cell.IsHoliday); + Assert.Equal("#BDBDBD", cell.ColorHex); + } + + [Fact] + public void Load_Wochenkachel_TagAusserhalbVonFerien_WirdNichtAusgegraut() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.False(cell.IsHoliday); + } + + [Fact] + public void Load_Wochenkachel_ExperimentInPhaseGeplant_ZeigtExperimentIcon() + { + 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 lessons = new FakeLessons(); + lessons.Add(new Lesson { GroupId = group.Id, Date = date, Phases = [new LessonPhaseStep { Activity = "Experiment: Redoxreihe" }] }); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.True(cell.HasExperiment); + } + + [Fact] + public void Load_Wochenkachel_OhneExperimentErwaehnung_KeinExperimentIcon() + { + 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 lessons = new FakeLessons(); + lessons.Add(new Lesson { GroupId = group.Id, Date = date, Phases = [new LessonPhaseStep { Activity = "Stillarbeit" }] }); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.False(cell.HasExperiment); + } + + [Fact] + public void Load_Wochenkachel_LetzteStundeVorKlausur_ZeigtIcon() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var exams = new FakeExams([new Exam { GroupId = group.Id, Date = date.AddDays(1), Title = "Klausur" }]); + var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.True(cell.IsLastBeforeExam); + } + + [Fact] + public void Load_Wochenkachel_KeineAnstehendeKlausur_KeinIcon() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.False(cell.IsLastBeforeExam); + } + + [Fact] + public void PreviousWeek_ZeigtVorherigeKalenderwoche() + { + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); + var initialLabel = vm.WeekRangeLabel; + + vm.PreviousWeekCommand.Execute(null); + + Assert.NotEqual(initialLabel, vm.WeekRangeLabel); + Assert.False(vm.IsCurrentWeek); + } + + [Fact] + public void NextWeek_GefolgtVonCurrentWeek_KehrtZurLaufendenWocheZurueck() + { + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([])); + var initialLabel = vm.WeekRangeLabel; + + vm.NextWeekCommand.Execute(null); + Assert.NotEqual(initialLabel, vm.WeekRangeLabel); + + vm.CurrentWeekCommand.Execute(null); + Assert.Equal(initialLabel, vm.WeekRangeLabel); + Assert.True(vm.IsCurrentWeek); + } + + [Fact] + public void NextWeek_BadgeGehoertZurAngezeigtenWoche_NichtZuHeute() + { + // Klausur liegt in der übernächsten Woche relativ zu "heute" — im Wochenraster einer + // Woche dahinter (also der übernächsten Woche selbst) muss "letzte Stunde vor Klausur" + // erscheinen, nicht bereits in der aktuellen Woche. + var group = new LearningGroup { Name = "Q1 Chemie" }; + var currentWeekMonday = DateInCurrentWeek(DayOfWeek.Monday); + var examWeekMonday = currentWeekMonday.AddDays(14); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var exams = new FakeExams([new Exam { GroupId = group.Id, Date = examWeekMonday.AddDays(1), Title = "Klausur" }]); + var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams); + + var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.False(currentWeekCell.IsLastBeforeExam); + + vm.NextWeekCommand.Execute(null); + vm.NextWeekCommand.Execute(null); + var examWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.True(examWeekCell.IsLastBeforeExam); + } + + // ── Badge: letzte/vorletzte Stunde vor Ferien (Bearbeiten-Raster) ──────────────────────── + + [Fact] + public void Load_LetzteStundeVorFerien_ZeigtBadge1() + { + var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today)); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 }); + var holidays = new FakeSchoolHolidays(); + holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = weekday.AddDays(1), EndDate = weekday.AddDays(10) }); + var vm = BuildViewModel(slots, new FakeGroups([group]), holidays); + + var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1); + Assert.Equal("1", cell.BadgeText); + } + + [Fact] + public void Load_Wochenkachel_LetzteStundeVorFerien_ZeigtBadge1() + { + var date = DateInCurrentWeek(DayOfWeek.Monday); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var holidays = new FakeSchoolHolidays(); + holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = date.AddDays(1), EndDate = date.AddDays(10) }); + var vm = BuildViewModel(slots, new FakeGroups([group]), holidays); + + var weekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1); + Assert.Equal("1", weekCell.HolidayBadge); + } + + [Fact] + public void Load_VorletzteStundeVorFerien_ZeigtBadge2() + { + var firstOccurrence = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today)); + var secondOccurrence = firstOccurrence.AddDays(7); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = firstOccurrence.DayOfWeek, PeriodNumber = 1 }); + var holidays = new FakeSchoolHolidays(); + holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = secondOccurrence.AddDays(1), EndDate = secondOccurrence.AddDays(10) }); + var vm = BuildViewModel(slots, new FakeGroups([group]), holidays); + + var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == firstOccurrence.DayOfWeek && c.PeriodNumber == 1); + Assert.Equal("2", cell.BadgeText); + } + + [Fact] + public void Load_Doppelstunde_BeideStundenZeigenDasselbeBadge() + { + var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today)); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 }); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 2 }); + var holidays = new FakeSchoolHolidays(); + holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = weekday.AddDays(1), EndDate = weekday.AddDays(10) }); + var vm = BuildViewModel(slots, new FakeGroups([group]), holidays); + + var period1 = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1); + var period2 = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 2); + Assert.Equal("1", period1.BadgeText); + Assert.Equal("1", period2.BadgeText); + } + + [Fact] + public void Load_KeineAnstehendenFerien_KeinBadge() + { + var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today)); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 }); + var vm = BuildViewModel(slots, new FakeGroups([group])); + + var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1); + Assert.False(cell.HasBadge); + } +} diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 46dabef..79bd861 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -6,6 +6,7 @@ using LehrerApp.Data; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.Views; using Microsoft.Extensions.DependencyInjection; @@ -103,6 +104,10 @@ public class App : Application var sl = Services.GetRequiredService(); sl.OnNavigateToDetail = id => main.NavigateToStudent(id); sl.OnAddStudent = () => ShowAddStudentDialog(); + + // Stundenplan "Heute" → GroupDetail (Tab "Planung") + var timetable = Services.GetRequiredService(); + timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 5); } private static async Task ShowAddStudentDialog() diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 958fb35..a6484f9 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -5,6 +5,7 @@ using LehrerApp.Data.Repositories; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Sync; @@ -130,10 +131,14 @@ 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)); // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── services.AddSingleton(_ => new EventQueue(queuePath)); @@ -178,6 +183,7 @@ public static class AppBootstrapper new SyncStatusViewModel(sp.GetService())); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Transient: neue Instanz pro Navigation (für Detailseiten) services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 0d5388b..629727b 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Students; using Microsoft.Extensions.DependencyInjection; @@ -64,7 +65,7 @@ public partial class MainWindowViewModel : ObservableObject NavItem.Groups => _services.GetRequiredService(), NavItem.Students => _services.GetRequiredService(), NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" }, - NavItem.Planner => new PlaceholderViewModel { Title = "Unterrichtsplanung", Icon = "📅" }, + NavItem.Planner => GetTimetable(), NavItem.Workload => new PlaceholderViewModel { Title = "Arbeitszeit", Icon = "⏱" }, NavItem.Settings => _services.GetRequiredService(), _ => CurrentPage, @@ -78,6 +79,15 @@ public partial class MainWindowViewModel : ObservableObject return dashboard; } + private TimetableViewModel GetTimetable() + { + var timetable = _services.GetRequiredService(); + timetable.WeekOffset = 0; + timetable.Load(); + timetable.ActiveTabIndex = 0; + return timetable; + } + public void NavigateToGroupDetail(Guid groupId, int initialTab = 0) { ActiveNavItem = NavItem.Groups; diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableSlotDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableSlotDialogViewModel.cs new file mode 100644 index 0000000..8b5ef8f --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableSlotDialogViewModel.cs @@ -0,0 +1,90 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels.Planning; + +/// Zuweisen/Bearbeiten/Entfernen eines Stundenplan-Termins (4.3.3). +public partial class TimetableSlotDialogViewModel : ObservableObject +{ + private readonly ITimetableSlotRepository _slots; + private readonly Dictionary _groupIdsByName; + private readonly TimetableSlot? _editing; + + public DayOfWeek Weekday { get; } + public int PeriodNumber { get; } + public string WeekdayLabel { get; } + public string DialogTitle { get; } + public bool IsEditing => _editing is not null; + + [ObservableProperty] private string _selectedGroupName = ""; + [ObservableProperty] private string _room = ""; + [ObservableProperty] private string _groupError = ""; + + public string[] GroupOptions { get; } + + /// null = unverändert/Abbruch, sonst das neue/aktualisierte Ergebnis. + public TimetableSlot? Result { get; private set; } + public bool Deleted { get; private set; } + + public TimetableSlotDialogViewModel(ITimetableSlotRepository slots, IGroupRepository groups, + SchoolYearService schoolYear, DayOfWeek weekday, int periodNumber, TimetableSlot? editing) + { + _slots = slots; + _editing = editing; + Weekday = weekday; + PeriodNumber = periodNumber; + WeekdayLabel = weekday switch + { + DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag", + DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag", + DayOfWeek.Friday => "Freitag", _ => weekday.ToString(), + }; + DialogTitle = $"{WeekdayLabel}, {periodNumber}. Stunde"; + + var availableGroups = groups.GetBySchoolYear(schoolYear.CurrentSchoolYear()).OrderBy(g => g.Name).ToList(); + _groupIdsByName = availableGroups.ToDictionary(g => g.Name, g => g.Id); + GroupOptions = availableGroups.Select(g => g.Name).ToArray(); + + if (editing is not null) + { + SelectedGroupName = availableGroups.FirstOrDefault(g => g.Id == editing.GroupId)?.Name ?? ""; + Room = editing.Room ?? ""; + } + } + + [RelayCommand] + private void Save() + { + GroupError = ""; + if (string.IsNullOrWhiteSpace(SelectedGroupName) || !_groupIdsByName.TryGetValue(SelectedGroupName, out var groupId)) + { + GroupError = "Bitte eine Gruppe auswählen."; + return; + } + + var slot = _editing ?? new TimetableSlot { Weekday = Weekday, PeriodNumber = PeriodNumber }; + slot.GroupId = groupId; + slot.Room = string.IsNullOrWhiteSpace(Room) ? null : Room.Trim(); + + try + { + _slots.Save(slot); + Result = slot; + } + catch (InvalidOperationException ex) + { + GroupError = ex.Message; + } + } + + [RelayCommand] + private void Delete() + { + if (_editing is null) return; + _slots.Delete(_editing.Id); + Deleted = true; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs new file mode 100644 index 0000000..81bb4f8 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -0,0 +1,456 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Planning; + +// ── Bundesland: deutsche Anzeige (4.3.5) ───────────────────────────────────── +// Wird sowohl vom Stundenplan (Anzeige) als auch von den Einstellungen (Pflege) verwendet. + +public static class GermanStateDisplay +{ + private static readonly (GermanState State, string Name)[] Entries = + [ + (GermanState.BW, "Baden-Württemberg"), + (GermanState.BY, "Bayern"), + (GermanState.BE, "Berlin"), + (GermanState.BB, "Brandenburg"), + (GermanState.HB, "Bremen"), + (GermanState.HH, "Hamburg"), + (GermanState.HE, "Hessen"), + (GermanState.MV, "Mecklenburg-Vorpommern"), + (GermanState.NI, "Niedersachsen"), + (GermanState.NW, "Nordrhein-Westfalen"), + (GermanState.RP, "Rheinland-Pfalz"), + (GermanState.SL, "Saarland"), + (GermanState.SN, "Sachsen"), + (GermanState.ST, "Sachsen-Anhalt"), + (GermanState.SH, "Schleswig-Holstein"), + (GermanState.TH, "Thüringen"), + ]; + + public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray(); + public static string Label(GermanState s) => Entries.First(e => e.State == s).Name; + public static GermanState FromLabel(string label) => + Entries.FirstOrDefault(e => e.Name == label).State; +} + +// ── Stundenplan: "Heute"-Übersicht (Standardansicht, Wochenraster + Tagesliste) + Bearbeiten (4.3) ── + +public partial class TimetableViewModel : ObservableObject +{ + private const int FirstPeriod = 1; + private const int LastPeriod = 10; + private static readonly DayOfWeek[] Weekdays = + [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday]; + private static readonly string[] WeekdayColorPalette = + ["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27", "#4C86A8"]; + + private readonly ITimetableSlotRepository _slots; + private readonly IGroupRepository _groups; + private readonly ISubjectRepository _subjects; + private readonly ILessonRepository _lessons; + private readonly IExamRepository _exams; + private readonly ISchoolHolidayRepository _schoolHolidays; + private readonly SchoolCalendarSettingsService _calendarSettings; + private readonly PublicHolidayService _publicHolidays; + private readonly SchoolYearService _schoolYear; + + public ObservableCollection Cells { get; } = []; + public ObservableCollection WeekItems { get; } = []; + public ObservableCollection HoursWarnings { get; } = []; + public ObservableCollection TodayItems { get; } = []; + + [ObservableProperty] private int _activeTabIndex; + [ObservableProperty] private string _todayLabel = ""; + [ObservableProperty] private string _weekRangeLabel = ""; + [ObservableProperty] private int _weekOffset; + public bool IsCurrentWeek => WeekOffset == 0; + + public Func? OnEditSlot { get; set; } + public Action? OnNavigateToGroup { get; set; } + + public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups, + ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, + ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, + PublicHolidayService publicHolidays, SchoolYearService schoolYear) + { + _slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; + _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; + _publicHolidays = publicHolidays; _schoolYear = schoolYear; + Load(); + } + + partial void OnWeekOffsetChanged(int value) => OnPropertyChanged(nameof(IsCurrentWeek)); + + [RelayCommand] + private void PreviousWeek() { WeekOffset--; Load(); } + + [RelayCommand] + private void NextWeek() { WeekOffset++; Load(); } + + [RelayCommand] + private void CurrentWeek() { WeekOffset = 0; Load(); } + + public void Load() + { + var today = DateOnly.FromDateTime(DateTime.Today); + TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); + + var publicHolidayDates = new HashSet(); + foreach (var year in new[] { today.Year - 1, today.Year, today.Year + 1 }) + foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date); + + var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates); + var examProximity = ComputeExamProximity(today, publicHolidayDates); + + BuildGrid(holidayBadges); + BuildWeekOverview(today, publicHolidayDates); + BuildToday(today); + BuildHoursWarnings(); + } + + // ── "Heute": Tagesliste ────────────────────────────────────────────────── + + private void BuildToday(DateOnly today) + { + TodayItems.Clear(); + var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).OrderBy(s => s.PeriodNumber).ToList(); + + foreach (var slot in slotsToday) + { + var group = _groups.GetById(slot.GroupId); + if (group is null) 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, + slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title)); + } + } + + [RelayCommand] + private void OpenGroup(Guid groupId) => OnNavigateToGroup?.Invoke(groupId); + + [RelayCommand] + private void ShowEditor() => ActiveTabIndex = 1; + + // ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ── + + /// + /// Zeigt Fach, Klasse, Raum und (falls für den Tag hinterlegt) das Thema der Stunde für die + /// per gewählte Kalenderwoche (Mo–Fr, wie das Bearbeiten-Raster) — + /// anders als die Tagesliste auch für Tage, die noch nicht "heute" sind, damit z.B. der + /// 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. + /// + private void BuildWeekOverview(DateOnly today, HashSet publicHolidayDates) + { + WeekItems.Clear(); + var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7); + var monday = currentWeekMonday.AddDays(WeekOffset * 7); + var friday = monday.AddDays(4); + WeekRangeLabel = $"{monday:dd.MM.} – {friday:dd.MM.yyyy}"; + + var allSlots = _slots.GetAll(); + 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)); + + WeekItems.Add(WeekCellItem.Corner()); + foreach (var weekday in Weekdays) + WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today)); + + for (var period = FirstPeriod; period <= LastPeriod; period++) + { + WeekItems.Add(WeekCellItem.PeriodLabel(period)); + foreach (var weekday in Weekdays) + { + 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 lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault(); + var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date); + var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates); + var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? ""); + var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates); + var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates); + + WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, + subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "", + group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "", + colorHex, holidayBadge, hasExam, isLastBeforeExam, + MentionsExperiment(lesson), slot.GroupId, isHoliday)); + } + } + } + + /// Badge "1"/"2" für die Zelle mit Datum selbst, analog zu + /// , aber an diesem konkreten Datum statt an "heute" + /// ausgerichtet — nötig, damit das Badge beim Blättern durch die Wochen zur richtigen Woche + /// gehört. + private string HolidayBadgeFor(DateOnly date, DayOfWeek weekday, List schoolHolidays, + HashSet publicHolidayDates) + { + var nextHoliday = schoolHolidays.Where(h => h.StartDate > date).MinBy(h => h.StartDate); + if (nextHoliday is null) return ""; + var count = CountOccurrences(weekday, date, nextHoliday.StartDate, publicHolidayDates); + return count is 1 or 2 ? count.ToString() : ""; + } + + /// Analog zu , aber am Zelldatum statt an "heute" + /// ausgerichtet. + private bool IsLastBeforeExamFor(DateOnly date, DayOfWeek weekday, Guid groupId, HashSet publicHolidayDates) + { + var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= date).MinBy(e => e.Date); + if (nextExam is null) return false; + return CountOccurrences(weekday, date, nextExam.Date, publicHolidayDates) == 1; + } + + private static bool MentionsExperiment(Lesson? lesson) => + lesson is not null && lesson.Phases.Any(p => + p.Name.Contains("Experiment", StringComparison.OrdinalIgnoreCase) || + p.Activity.Contains("Experiment", StringComparison.OrdinalIgnoreCase) || + p.Material.Contains("Experiment", StringComparison.OrdinalIgnoreCase)); + + /// Fällt auf einen gesetzlichen Feiertag oder in Schulferien? + private static bool IsFreeDay(DateOnly date, List schoolHolidays, HashSet publicHolidayDates) => + publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate); + + // ── Bearbeiten-Raster ───────────────────────────────────────────────────── + + private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges) + { + Cells.Clear(); + var allSlots = _slots.GetAll(); + var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name); + + Cells.Add(TimetableCellItem.Corner()); + foreach (var weekday in Weekdays) + Cells.Add(TimetableCellItem.WeekdayHeader(weekday)); + + for (var period = FirstPeriod; period <= LastPeriod; period++) + { + Cells.Add(TimetableCellItem.PeriodLabel(period)); + foreach (var weekday in Weekdays) + { + var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period); + var groupName = slot is not null ? groupNames.GetValueOrDefault(slot.GroupId, "?") : ""; + var badge = slot is not null ? badges.GetValueOrDefault((weekday, slot.GroupId), "") : ""; + Cells.Add(TimetableCellItem.ForSlot(weekday, period, slot, groupName, ColorFor(groupName), badge)); + } + } + } + + private static string ColorFor(string name) + { + if (name.Length == 0) return "#9E9E9E"; + var hash = 0; + foreach (var c in name) hash = hash * 31 + c; + return WeekdayColorPalette[Math.Abs(hash) % WeekdayColorPalette.Length]; + } + + [RelayCommand] + private async Task EditCell(TimetableCellItem? cell) + { + if (cell is null || !cell.IsSlotCell || OnEditSlot is null) return; + await OnEditSlot(cell); + Load(); + } + + // ── Abgleich mit Wochenstunden (4.3.4) ──────────────────────────────────── + + private void BuildHoursWarnings() + { + HoursWarnings.Clear(); + var currentYear = _schoolYear.CurrentSchoolYear(); + var slotCounts = _slots.GetAll().GroupBy(s => s.GroupId).ToDictionary(g => g.Key, g => g.Count()); + + foreach (var group in _groups.GetBySchoolYear(currentYear).Where(g => g.HoursPerWeek.HasValue)) + { + var assigned = slotCounts.GetValueOrDefault(group.Id, 0); + if (assigned != group.HoursPerWeek!.Value) + HoursWarnings.Add(new HoursWarningItem(group.Name, assigned, group.HoursPerWeek.Value)); + } + } + + // ── Badges: letzte/vorletzte Stunde vor Ferien, letzte Stunde vor Klausur ──────────────── + + /// + /// Zählt, wie oft zwischen (inkl.) und + /// (exkl.) eintritt — gesetzliche Feiertage werden übersprungen, da + /// an ihnen ohnehin kein Unterricht stattfindet. + /// + private static int CountOccurrences(DayOfWeek weekday, DateOnly from, DateOnly until, HashSet publicHolidays) + { + var count = 0; + for (var date = from; date < until; date = date.AddDays(1)) + if (date.DayOfWeek == weekday && !publicHolidays.Contains(date)) count++; + return count; + } + + /// + /// Badge "1"/"2" für die letzte bzw. vorletzte Stunde eines Wochentags vor den nächsten + /// anstehenden Schulferien. Pro (Wochentag, Gruppe) statt pro einzelnem + /// berechnet: eine Doppelstunde besteht aus zwei Slots mit demselben Wochentag/derselben + /// Gruppe und bekommt dadurch automatisch dasselbe Badge, ohne gesonderte Blockerkennung. + /// + private Dictionary<(DayOfWeek Weekday, Guid GroupId), string> ComputeHolidayBadges( + DateOnly today, HashSet publicHolidayDates) + { + var result = new Dictionary<(DayOfWeek, Guid), string>(); + var nextHoliday = _schoolHolidays.GetAll().Where(h => h.StartDate > today).MinBy(h => h.StartDate); + if (nextHoliday is null) return result; + + foreach (var group in _slots.GetAll().GroupBy(s => (s.Weekday, s.GroupId))) + { + var count = CountOccurrences(group.Key.Weekday, today, nextHoliday.StartDate, publicHolidayDates); + if (count is 1 or 2) result[group.Key] = count.ToString(); + } + return result; + } + + /// + /// Markiert die letzte Stunde eines Wochentags vor der nächsten anstehenden Klausur derselben + /// Gruppe (sofern eine ansteht) — analog zum Ferien-Badge, aber je Gruppe an deren eigenem + /// nächsten Klausurtermin statt an einem gemeinsamen Ferientermin ausgerichtet. + /// + private Dictionary<(DayOfWeek Weekday, Guid GroupId), bool> ComputeExamProximity( + DateOnly today, HashSet publicHolidayDates) + { + var result = new Dictionary<(DayOfWeek, Guid), bool>(); + foreach (var groupSlots in _slots.GetAll().GroupBy(s => s.GroupId)) + { + var nextExam = _exams.GetByGroup(groupSlots.Key).Where(e => e.Date >= today).MinBy(e => e.Date); + if (nextExam is null) continue; + + foreach (var weekday in groupSlots.Select(s => s.Weekday).Distinct()) + { + var count = CountOccurrences(weekday, today, nextExam.Date, publicHolidayDates); + if (count == 1) result[(weekday, groupSlots.Key)] = true; + } + } + return result; + } +} + +public class TimetableCellItem +{ + public bool IsHeader { get; private init; } + public bool IsPeriodLabel { get; private init; } + public string Text { get; private init; } = ""; + public DayOfWeek? Weekday { get; private init; } + public int PeriodNumber { get; private init; } + public TimetableSlot? Slot { get; private init; } + public string GroupName { get; private init; } = ""; + public string ColorHex { get; private init; } = "#9E9E9E"; + public string BadgeText { get; private init; } = ""; + public bool HasBadge => BadgeText.Length > 0; + public bool IsAssigned => Slot is not null; + public bool IsSlotCell => !IsHeader && !IsPeriodLabel; + + public static TimetableCellItem Corner() => new() { IsHeader = true, Text = "" }; + + public static TimetableCellItem WeekdayHeader(DayOfWeek day) => new() + { + IsHeader = true, + Text = day.ToString() switch + { + "Monday" => "Mo", "Tuesday" => "Di", "Wednesday" => "Mi", + "Thursday" => "Do", "Friday" => "Fr", _ => day.ToString(), + }, + }; + + public static TimetableCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() }; + + public static TimetableCellItem ForSlot(DayOfWeek day, int period, TimetableSlot? slot, string groupName, + string colorHex, string badgeText) => new() + { + Weekday = day, PeriodNumber = period, Slot = slot, GroupName = groupName, ColorHex = colorHex, + BadgeText = badgeText, + Text = slot is null ? "" : groupName + (string.IsNullOrWhiteSpace(slot.Room) ? "" : $" · {slot.Room}"), + }; +} + +/// Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht. +public class WeekCellItem +{ + public bool IsHeader { get; private init; } + public bool IsPeriodLabel { get; private init; } + public bool IsSlotCell => !IsHeader && !IsPeriodLabel; + public bool IsAssigned { get; private init; } + public string Text { get; private init; } = ""; + public DayOfWeek? Weekday { get; private init; } + public int PeriodNumber { get; private init; } + public bool IsToday { get; private init; } + public Guid GroupId { get; private init; } + public string SubjectLabel { get; private init; } = ""; + public string GroupName { get; private init; } = ""; + public string Room { get; private init; } = ""; + public string Topic { get; private init; } = ""; + public string ColorHex { get; private init; } = "#9E9E9E"; + public string HolidayBadge { get; private init; } = ""; + public bool HasHolidayBadge => HolidayBadge.Length > 0; + public bool HasExam { get; private init; } + public bool IsLastBeforeExam { get; private init; } + public bool HasExperiment { get; private init; } + public bool IsHoliday { get; private init; } + public bool HasRoom => !string.IsNullOrWhiteSpace(Room); + public bool HasTopic => !string.IsNullOrWhiteSpace(Topic); + + public static WeekCellItem Corner() => new() { IsHeader = true }; + + public static WeekCellItem WeekdayHeader(DayOfWeek day, DateOnly date, bool isToday) => new() + { + IsHeader = true, + IsToday = isToday, + Text = (day switch + { + DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi", + DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => day.ToString(), + }) + $" {date:dd.MM.}", + }; + + public static WeekCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() }; + + public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period }; + + 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() + { + Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, + SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic, + ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam, + IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId, + IsHoliday = isHoliday, + }; +} + +public class HoursWarningItem(string groupName, int assigned, int expected) +{ + public string GroupName { get; } = groupName; + 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 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 bool HasRoom => !string.IsNullOrWhiteSpace(Room); + public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic); + public bool HasExam => ExamTitle is not null; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 26be24b..03b2f4f 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -4,7 +4,9 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; +using LehrerApp.Desktop.ViewModels.Planning; using System.Collections.ObjectModel; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -113,14 +115,30 @@ public partial class SettingsViewModel : ObservableObject /// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen. public Func>? OnConfirmHardDelete { get; set; } + // ── Ferien & Feiertage (4.3.5, aus dem Stundenplan hierher verschoben) ─── + + [ObservableProperty] private string _selectedStateName = ""; + [ObservableProperty] private string _newHolidayName = ""; + [ObservableProperty] private string _newHolidayStartText = ""; + [ObservableProperty] private string _newHolidayEndText = ""; + [ObservableProperty] private string _holidayNameError = ""; + [ObservableProperty] private string _holidayDateError = ""; + + public List StateOptions { get; } = GermanStateDisplay.Options.ToList(); + public ObservableCollection SchoolHolidayEntries { get; } = []; + // ── Konstruktor ─────────────────────────────────────────────────────────── + private readonly ISchoolHolidayRepository _schoolHolidays; + private readonly SchoolCalendarSettingsService _calendarSettings; + public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy, IDocumentationRepository documentation, IStudentRepository students, - IShorthandCodeRepository shorthandCodes) + IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, + SchoolCalendarSettingsService calendarSettings) { _subjects = subjects; _domainRepo = domainRepo; @@ -135,6 +153,8 @@ public partial class SettingsViewModel : ObservableObject _documentation = documentation; _students = students; _shorthandCodes = shorthandCodes; + _schoolHolidays = schoolHolidays; + _calendarSettings = calendarSettings; LoadSubjects(); LoadShorthandCodes(); LoadGradingKeyTemplates(); @@ -145,6 +165,51 @@ public partial class SettingsViewModel : ObservableObject AppLockTimeoutMinutes = _appLock.TimeoutMinutes; RetentionYears = _privacy.RetentionYears; LoadExpiredDocuments(); + SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State); + LoadSchoolHolidays(); + } + + // ── Ferien & Feiertage: Bundesland / Schulferien pflegen ───────────────── + + partial void OnSelectedStateNameChanged(string value) => + _calendarSettings.SetState(GermanStateDisplay.FromLabel(value)); + + private void LoadSchoolHolidays() + { + SchoolHolidayEntries.Clear(); + foreach (var h in _schoolHolidays.GetAll().OrderBy(h => h.StartDate)) + SchoolHolidayEntries.Add(new SchoolHolidayItem(h)); + } + + [RelayCommand] + private void AddSchoolHoliday() + { + HolidayNameError = ""; HolidayDateError = ""; + var valid = true; + + if (string.IsNullOrWhiteSpace(NewHolidayName)) { HolidayNameError = "Name erforderlich."; valid = false; } + + var hasStart = DateOnly.TryParseExact(NewHolidayStartText, "dd.MM.yyyy", CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var start); + var hasEnd = DateOnly.TryParseExact(NewHolidayEndText, "dd.MM.yyyy", CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var end); + + if (!hasStart || !hasEnd) { HolidayDateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; valid = false; } + else if (end < start) { HolidayDateError = "Das Ende darf nicht vor dem Beginn liegen."; valid = false; } + + if (!valid) return; + + _schoolHolidays.Save(new SchoolHoliday { Name = NewHolidayName.Trim(), StartDate = start, EndDate = end }); + NewHolidayName = ""; NewHolidayStartText = ""; NewHolidayEndText = ""; + LoadSchoolHolidays(); + } + + [RelayCommand] + private void RemoveSchoolHoliday(SchoolHolidayItem? item) + { + if (item is null) return; + _schoolHolidays.Delete(item.Id); + SchoolHolidayEntries.Remove(item); } // ── Datenschutz: Löschfristen ───────────────────────────────────────────── @@ -770,6 +835,13 @@ public class ExpiredDocumentItem(Documentation d, string studentName) public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy"); } +public class SchoolHolidayItem(SchoolHoliday h) +{ + public Guid Id { get; } = h.Id; + public string Name { get; } = h.Name; + public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} – {h.EndDate:dd.MM.yyyy}"; +} + // ── JSON DTOs ───────────────────────────────────────────────────────────────── internal class CatalogDto diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml b/LehrerApp.Desktop/Views/MainWindow.axaml index 6cc0970..c2d44dc 100644 --- a/LehrerApp.Desktop/Views/MainWindow.axaml +++ b/LehrerApp.Desktop/Views/MainWindow.axaml @@ -9,6 +9,8 @@ xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students" xmlns:vset="clr-namespace:LehrerApp.Desktop.Views.Settings" xmlns:vmset="clr-namespace:LehrerApp.Desktop.ViewModels.Settings" + xmlns:vp="clr-namespace:LehrerApp.Desktop.Views.Planning" + xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning" xmlns:svc="clr-namespace:LehrerApp.Desktop.Services" x:Class="LehrerApp.Desktop.Views.MainWindow" x:DataType="vm:MainWindowViewModel" @@ -48,6 +50,9 @@ + + + diff --git a/LehrerApp.Desktop/Views/Planning/TimetableSlotDialog.axaml b/LehrerApp.Desktop/Views/Planning/TimetableSlotDialog.axaml new file mode 100644 index 0000000..e428ceb --- /dev/null +++ b/LehrerApp.Desktop/Views/Planning/TimetableSlotDialog.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +