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; }