using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; using System.Globalization; namespace LehrerApp.Desktop.ViewModels; public partial class DashboardViewModel : ObservableObject { private static readonly CultureInfo De = new("de-DE"); private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; private readonly ILessonRepository _lessons; private readonly IExamRepository _exams; private readonly IExamResultRepository _examResults; private readonly IGradeRepository _grades; private readonly IReportGradeRepository _reportGrades; private readonly IGroupMembershipRepository _memberships; private readonly IWorkTaskRepository _tasks; private readonly IParticipationSessionRepository _participationSessions; private readonly IParticipationRepository _participationEntries; private readonly IStudentRepository _students; private readonly IDocumentationRepository _documentation; private readonly ITimetableSlotRepository _timetableSlots; private readonly PeriodScheduleService _periodSchedule; private readonly AttendanceBalanceService _attendanceBalance; private readonly SchoolYearService _sy; private readonly DashboardSettingsService _dashboardSettings; private readonly ISchoolHolidayRepository _schoolHolidays; private readonly PublicHolidayService _publicHolidays; private readonly SchoolCalendarSettingsService _calendarSettings; private readonly ISubstitutionEntryRepository _substitutions; private readonly ITimeEntryRepository _timeEntries; private readonly IAnnualPlanEventRepository? _annualPlanEvents; private readonly SchoolWeatherService? _schoolWeather; private const int OpenExcuseMaxAgeDays = 21; private const int SupportPlanDueWithinDays = 14; private const int UpcomingWithinDays = 30; /// Nutzer-Feedback: "der zeitliche Vorgriff sollte sinnvoll sein" - heute + morgen ist knapp /// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug, /// um sich abends noch vorzubereiten. private const int UnplannedLessonsLookaheadDays = 1; /// Nutzer-Feedback: unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden /// Schuljahr wird eine hohe Fehlquote NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, /// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine /// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize. private const int AttendanceMinSampleSize = 8; /// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal /// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht /// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren /// geht, sobald der nächste Schultag anbricht. private const int MissingTeachingTimeLookbackDays = 14; /// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen — /// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende. private const int MissingTeachingTimeTodayDelayMinutes = 30; /// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier /// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung). private const int TeachingWindowBufferBeforeMinutes = 15; private const int TeachingWindowBufferAfterMinutes = 10; /// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich /// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber /// keine Vorschau auf das ganze Schuljahr. private const int ExamLoadLookaheadDays = 60; [ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); [ObservableProperty] private string _selectedDayLabel = ""; [ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today); [ObservableProperty] private bool _isDashboardSettingsOpen; [ObservableProperty] private bool _isWeatherPanelVisible; [ObservableProperty] private string _weatherSummary = ""; [ObservableProperty] private string _weatherDetails = ""; [ObservableProperty] private string _weatherStatus = ""; [ObservableProperty] private bool _hasWeatherWarnings; public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De); public ObservableCollection TodaysLessons { get; } = []; public ObservableCollection OpenTasks { get; } = []; public ObservableCollection CurrentGroups { get; } = []; public ObservableCollection CalendarDays { get; } = []; public ObservableCollection OpenExcuses { get; } = []; public ObservableCollection AttendanceWarnings { get; } = []; public ObservableCollection ExamWeekLoads { get; } = []; public ObservableCollection MissingTeachingTimeEntries { get; } = []; public ObservableCollection SupportPlanReviews { get; } = []; public ObservableCollection UpcomingDates { get; } = []; public ObservableCollection OpenCorrections { get; } = []; public ObservableCollection UnplannedLessons { get; } = []; public ObservableCollection Alerts { get; } = []; public ObservableCollection SelectedDayEvents { get; } = []; public ObservableCollection DashboardCards { get; } = []; public ObservableCollection WeatherWarnings { get; } = []; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; // Navigation-Callback – wird von App.axaml.cs verdrahtet public Action? OnNavigateToGroup { get; set; } public Action? OnNavigateToStudent { get; set; } // Sprungziel eigens für eine Stunde in TodaysLessons (9.2) — bewusst getrennt von // OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten // Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt. public Action? OnNavigateToLesson { get; set; } // Direkter Anlege-Einstieg aus der "Offene Aufgaben"-Kachel (Nutzer-Feedback), statt erst über // "Arbeitszeit" navigieren zu müssen — gleiches Dialog-Delegate-Muster wie im Kurs-Dashboard. public Func>? OnAddTask { get; set; } public Action? OnNavigateToExam { get; set; } // Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe // (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann. public Action? OnNavigateToUnplannedLesson { get; set; } // Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung // (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan // berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog. public Func? OnAddMissingTeachingTime { get; set; } public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption CalendarCard => Card("calendar"); public DashboardCardOption ExcusesCard => Card("excuses"); public DashboardCardOption UpcomingCard => Card("upcoming"); public DashboardCardOption CorrectionsCard => Card("corrections"); public DashboardCardOption UnplannedCard => Card("unplanned"); public DashboardCardOption AlertsCard => Card("alerts"); public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption ExamLoadCard => Card("examload"); public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime"); public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption GroupsCard => Card("groups"); public int TodayLessonCount => TodaysLessons.Count; public int OpenTaskCount => OpenTasks.Count; public int UpcomingCount => UpcomingDates.Count; public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count + OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count + MissingTeachingTimeEntries.Count; public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden"; public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben"; public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine"; public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, IReportGradeRepository reportGrades, IGroupMembershipRepository memberships, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions, IParticipationRepository participationEntries, IStudentRepository students, IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries, IAnnualPlanEventRepository? annualPlanEvents = null, AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) { _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _participationSessions = participationSessions; _participationEntries = participationEntries; _students = students; _documentation = documentation; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _timeEntries = timeEntries; _substitutions = substitutions; _annualPlanEvents = annualPlanEvents; _schoolWeather = schoolWeather; if (annualPlanSync is not null) { annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); } LoadDashboardCards(); Load(); } private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key); private static DateOnly FirstOfMonth(DateTime d) => new(d.Year, d.Month, 1); private void Load() { var now = DateTime.Now; var today = DateOnly.FromDateTime(now); CurrentDate = now.ToString("dddd, d. MMMM yyyy", De); CurrentSchoolYear = _sy.CurrentSchoolYear(); Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend"; _ = LoadWeatherAsync(); TodaysLessons.Clear(); var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id); foreach (var l in groups.Keys.SelectMany(gid => _lessons.GetByGroupAndDate(gid, today)) .OrderBy(l => l.LessonNumber)) { if (!groups.TryGetValue(l.GroupId, out var g)) continue; // Raum kommt aus dem Stundenplan-Slot (4.3), sofern die Stunde eine Stundennummer hat // und dafür ein Slot am heutigen Wochentag existiert — Vertretungen/Ausfälle werden // hier bewusst NICHT berücksichtigt (das leistet bereits die "Heute"-Ansicht im // Stundenplan selbst, eine Dopplung dieser Logik wäre hier nicht sinnvoll). var slot = l.LessonNumber is int period ? _timetableSlots.GetByGroup(l.GroupId).FirstOrDefault(s => s.Weekday == today.DayOfWeek && s.PeriodNumber == period) : null; var timeDisplay = l.StartTime is { } st ? st.ToString("HH:mm") : l.LessonNumber is int p2 && _periodSchedule.GetTimes(p2) is { } t ? t.Start.ToString("HH:mm") : ""; TodaysLessons.Add(new() { LessonId = l.Id, GroupId = l.GroupId, GroupName = g.Name, Topic = l.Topic, TimeDisplay = timeDisplay, Room = slot?.Room ?? "", }); } OpenTasks.Clear(); foreach (var t in _tasks.GetByStatus(WorkTaskStatus.Open) .Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress)) .OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5)) OpenTasks.Add(new() { Title = t.Title, DueDate = t.DueDate?.ToString("dd.MM.") ?? "", IsOverdue = t.DueDate.HasValue && t.DueDate < today, IsReminder = t.Kind == TaskKind.Reminder, IsHighPriority = t.Priority == TaskPriority.High }); CurrentGroups.Clear(); foreach (var g in groups.Values) CurrentGroups.Add(new() { GroupId = g.Id, Name = g.Name, Subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "", }); CalendarMonth = FirstOfMonth(now); LoadCalendar(); LoadOpenExcuses(groups.Values.ToList(), today); LoadAttendanceWarnings(today); LoadExamWeekLoads(groups, today); LoadMissingTeachingTime(today); LoadSupportPlanReviews(today); LoadUpcomingDates(groups, today); LoadOpenCorrections(groups, today); LoadUnplannedLessons(groups, today); LoadAlerts(groups, today); UpdateDashboardSummary(); } private void UpdateDashboardSummary() { TodayCard.IsEmpty = TodaysLessons.Count == 0; TasksCard.IsEmpty = OpenTasks.Count == 0; CalendarCard.IsEmpty = false; ExcusesCard.IsEmpty = OpenExcuses.Count == 0; UpcomingCard.IsEmpty = UpcomingDates.Count == 0; CorrectionsCard.IsEmpty = OpenCorrections.Count == 0; UnplannedCard.IsEmpty = UnplannedLessons.Count == 0; AlertsCard.IsEmpty = Alerts.Count == 0; AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0; SupportCard.IsEmpty = SupportPlanReviews.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0; OnPropertyChanged(nameof(TodayLessonCount)); OnPropertyChanged(nameof(OpenTaskCount)); OnPropertyChanged(nameof(UpcomingCount)); OnPropertyChanged(nameof(AttentionCount)); OnPropertyChanged(nameof(TodayLessonSummary)); OnPropertyChanged(nameof(OpenTaskSummary)); OnPropertyChanged(nameof(AttentionSummary)); OnPropertyChanged(nameof(UpcomingSummary)); } private async Task LoadWeatherAsync() { if (_schoolWeather?.IsAvailable != true) return; try { var snapshot = await _schoolWeather.GetWeatherAsync(); if (snapshot is null) return; var current = snapshot.Forecast .Where(x => x.ValidAt.ToUniversalTime() >= DateTime.UtcNow.AddHours(-1)) .MinBy(x => Math.Abs((x.ValidAt.ToUniversalTime() - DateTime.UtcNow).TotalMinutes)); WeatherWarnings.Clear(); foreach (var warning in snapshot.Warnings) WeatherWarnings.Add(new DashboardWeatherWarningItem(warning)); HasWeatherWarnings = WeatherWarnings.Count > 0; if (current is not null) { var temperature = current.TemperatureC is { } t ? $"{t:0.#} °C" : "Temperatur unbekannt"; WeatherSummary = $"{temperature} · {WeatherDescription(current.WeatherCode)}"; var details = new List(); if (current.WindSpeedKmh is { } wind) details.Add($"Wind {wind:0.#} km/h"); if (current.WindGustKmh is { } gust) details.Add($"Böen {gust:0.#} km/h"); if (current.PrecipitationMm is > 0) details.Add($"Niederschlag {current.PrecipitationMm:0.#} mm"); details.Add($"DWD-Station {snapshot.StationName} ({snapshot.StationDistanceKm:0.#} km)"); WeatherDetails = string.Join(" · ", details); } WeatherStatus = snapshot.IsStale ? $"Letzter verfügbarer Stand vom {snapshot.RetrievedAt.ToLocalTime():dd.MM., HH:mm} Uhr" : ""; IsWeatherPanelVisible = current is not null || HasWeatherWarnings; } catch (SchoolWeatherException ex) { WeatherStatus = ex.Message; IsWeatherPanelVisible = true; } } private static string WeatherDescription(int? code) => code switch { 0 => "klar", >= 1 and <= 3 => "bewölkt", 45 or 48 => "Nebel", >= 51 and <= 67 => "Regen", >= 71 and <= 77 => "Schnee", >= 80 and <= 82 => "Regenschauer", 85 or 86 => "Schneeschauer", >= 95 and <= 99 => "Gewitter", _ => "Wettervorhersage", }; // ── Fehlzeiten-Warnung (5.2.3) ──────────────────────────────────────────── private void LoadAttendanceWarnings(DateOnly today) { AttendanceWarnings.Clear(); var schoolYear = _sy.CurrentSchoolYear(); var from = _sy.SchoolYearStart(schoolYear); var to = _sy.SchoolYearEnd(schoolYear); // Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers // einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen). var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date); var items = new List(); foreach (var student in _students.GetAll()) { var entries = _participationEntries.GetByStudent(student.Id) .Select(e => sessionDates.TryGetValue(e.SessionId, out var date) ? ((DateOnly?)date, e.Attendance) : (null, e.Attendance)) .Where(t => t.Item1.HasValue) .Select(t => (t.Item1!.Value, t.Attendance)); var balance = _attendanceBalance.Calculate(entries, from, to); if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold) items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); } foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) AttendanceWarnings.Add(item); } // ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── // // Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene // Kollisionsprüfung, die bereits der schulische Klausurplaner übernimmt (siehe // ExamWeekLoadService). Nur geplante, noch bevorstehende Klausuren zählen: die // Vorausschau soll helfen, eine sich anbahnende Woche zu erkennen, bevor man eine weitere // Klausur genau dort einträgt — bereits durchgeführte/korrigierte Klausuren werden schon // von der Karte "Offene Korrekturen" abgedeckt. private void LoadExamWeekLoads(IReadOnlyDictionary groups, DateOnly today) { ExamWeekLoads.Clear(); var lastDay = today.AddDays(ExamLoadLookaheadDays); var upcoming = groups.Keys .SelectMany(gid => _exams.GetByGroup(gid)) .Where(e => e.Status == ExamStatus.Planned && e.Date >= today && e.Date <= lastDay); foreach (var week in ExamWeekLoadService.FindOverloadedWeeks(upcoming)) ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount)); } // ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ───────────────── // // "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut // deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald // irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue // Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie // beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute). private void LoadMissingTeachingTime(DateOnly today) { MissingTeachingTimeEntries.Clear(); var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays); var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .Select(h => h.Date).ToHashSet(); var schoolHolidays = _schoolHolidays.GetAll(); var nowTime = TimeOnly.FromDateTime(DateTime.Now); var items = new List(); for (var date = firstDay; date <= today; date = date.AddDays(1)) { if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue; var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList(); if (daySlots.Count == 0) continue; var cancelledPeriods = _substitutions.GetByDate(date) .Where(s => s.Kind == SubstitutionKind.Cancelled) .Select(s => s.PeriodNumber).ToHashSet(); var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber)) .Select(s => _periodSchedule.GetTimes(s.PeriodNumber)) .Where(t => t is not null).Select(t => t!.Value).ToList(); if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt var lastPeriodEnd = periodTimes.Max(t => t.End); if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes)) continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue; var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes); var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes); items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd)); } foreach (var item in items.OrderBy(i => i.Date)) MissingTeachingTimeEntries.Add(item); } // ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── private void LoadSupportPlanReviews(DateOnly today) { SupportPlanReviews.Clear(); var dueBy = today.AddDays(SupportPlanDueWithinDays); var due = _documentation.GetAll() .Where(d => d.Type == DocumentationType.SupportPlan && d.SupportData is { Status: SupportStatus.Active, ReviewDate: not null } && d.SupportData.ReviewDate!.Value <= dueBy) .OrderBy(d => d.SupportData!.ReviewDate); foreach (var d in due) { var student = _students.GetById(d.StudentId); if (student is null) continue; SupportPlanReviews.Add(new SupportPlanDueItem( d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today)); } } // ── Anstehende Termine (9.3) ───────────────────────────────────────────── private void LoadUpcomingDates(IReadOnlyDictionary groups, DateOnly today) { UpcomingDates.Clear(); var dueBy = today.AddDays(UpcomingWithinDays); var items = new List(); foreach (var group in groups.Values) foreach (var exam in _exams.GetByGroup(group.Id) .Where(e => e.Date >= today && e.Date <= dueBy && e.Status == ExamStatus.Planned)) items.Add(new UpcomingDateItem(UpcomingDateKind.Exam, exam.Date, exam.Title, group.Name, group.Id, null, today)); foreach (var task in _tasks.GetByStatus(WorkTaskStatus.Open) .Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress)) .Where(t => t.DueDate.HasValue && t.DueDate.Value <= dueBy)) items.Add(new UpcomingDateItem(UpcomingDateKind.Deadline, task.DueDate!.Value, task.Title, task.GroupId is Guid groupId && groups.TryGetValue(groupId, out var group) ? group.Name : "Aufgabe", task.GroupId, null, today)); foreach (var doc in _documentation.GetAll() .Where(d => d.Type == DocumentationType.SupportPlan && d.SupportData is { Status: SupportStatus.Active, ReviewDate: not null } && d.SupportData.ReviewDate.Value <= dueBy)) { var student = _students.GetById(doc.StudentId); if (student is not null) items.Add(new UpcomingDateItem(UpcomingDateKind.SupportPlan, doc.SupportData!.ReviewDate!.Value, doc.Title, student.FullName, doc.GroupId, doc.StudentId, today)); } foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.Title).Take(8)) UpcomingDates.Add(item); } // ── Offene Korrekturen (9.4) ───────────────────────────────────────────── private void LoadOpenCorrections(IReadOnlyDictionary groups, DateOnly today) { OpenCorrections.Clear(); foreach (var group in groups.Values) foreach (var exam in _exams.GetByGroup(group.Id) .Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) .OrderBy(e => e.Date)) { var (expected, evaluated) = ExamCorrectionCounter.Count(exam, _memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id)); OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, group.Name, exam.Date, evaluated, expected, today)); } } // ── Ungeplante Stunden (Nutzer-Feedback: Erinnerung an Stunden ohne Thema) ──────────────── // // Nur heute + UnplannedLessonsLookaheadDays (bewusst knapp, siehe Konstante oben) — für jede // Gruppe mit RequiresLessonPlanning und einem Stundenplan-Slot an diesem Wochentag wird // geprüft, ob dafür bereits eine Lesson mit Thema existiert. Gruppen ohne eigenen Verlaufsplan // (Klassenrat, Willkommenskreis) lassen sich in den Stammdaten der Gruppe ausnehmen. Ein Slot, // für den an diesem Datum ein SubstitutionKind.Cancelled-Eintrag (4.3, "Stundenausfall") // vorliegt, fällt ganz weg — gleiche Prüfung (Datum + Stundennummer, ohne Gruppenbezug) wie in // TimetableViewModel.BuildToday. private void LoadUnplannedLessons(IReadOnlyDictionary groups, DateOnly today) { UnplannedLessons.Clear(); var lastDay = today.AddDays(UnplannedLessonsLookaheadDays); var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .Select(h => h.Date).ToHashSet(); var schoolHolidays = _schoolHolidays.GetAll(); var items = new List(); foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning)) { var slots = _timetableSlots.GetByGroup(group.Id); if (slots.Count == 0) continue; for (var date = today; date <= lastDay; date = date.AddDays(1)) { if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue; var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList(); if (daySlots.Count == 0) continue; var periodsWithSlot = daySlots.Select(s => s.PeriodNumber).ToHashSet(); var dayLessons = _lessons.GetByGroupAndDate(group.Id, date).ToList(); var cancelledPeriods = _substitutions.GetByDate(date) .Where(s => s.Kind == SubstitutionKind.Cancelled) .Select(s => s.PeriodNumber).ToHashSet(); foreach (var slot in daySlots) { if (cancelledPeriods.Contains(slot.PeriodNumber)) continue; var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == slot.PeriodNumber); if (lesson is not null) { if (!string.IsNullOrWhiteSpace(lesson.Topic)) continue; } else if (IsCoveredByEarlierDoppelstunde(dayLessons, periodsWithSlot, slot.PeriodNumber)) { continue; } items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today)); } } } foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName)) UnplannedLessons.Add(item); } /// Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die /// bei einer früheren Stundennummer beginnt — gleiches Prinzip wie /// PlanningViewModels.LessonDialogViewModel.RecomputeTimeBudget: ausgehend von der Vorperiode /// wird rückwärts so lange die jeweils vorherige Periode geprüft, wie der Stundenplan dafür /// noch einen Slot hat. Trifft man dabei auf eine Lesson, entscheidet deren Thema (vorhanden = /// Doppelstunde bereits geplant); trifft man auf eine Periode ohne Lesson, wird weiter /// zurückgegangen; bricht die Slot-Kette ab, ohne eine Lesson gefunden zu haben, ist die Periode /// nicht abgedeckt. private static bool IsCoveredByEarlierDoppelstunde( List dayLessons, HashSet periodsWithSlot, int periodNumber) { for (var period = periodNumber - 1; periodsWithSlot.Contains(period); period--) { var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == period); if (lesson is not null) return !string.IsNullOrWhiteSpace(lesson.Topic); } return false; } /// Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in /// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein /// Service-Aufwand für eine dritte Fundstelle. private static bool IsFreeDay(DateOnly date, List schoolHolidays, HashSet publicHolidayDates) => publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate); // ── Auffälligkeiten (9.5) ──────────────────────────────────────────────── private void LoadAlerts(IReadOnlyDictionary groups, DateOnly today) { Alerts.Clear(); foreach (var warning in AttendanceWarnings) Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName, "Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High)); foreach (var group in groups.Values) { var groupReportGrades = _reportGrades.GetByGroup(group.Id); foreach (var membership in _memberships.GetByGroup(group.Id) .Where(m => GroupMembershipService.IsActiveOn(m, today))) { var student = _students.GetById(membership.StudentId); if (student is null) continue; var values = _grades.GetByStudentAndGroup(student.Id, group.Id) .OrderBy(g => g.Date) .Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null) .Where(v => v.HasValue).Select(v => v!.Value).ToList(); if (values.Count >= 4) { var previous = values.TakeLast(4).Take(2).Average(); var recent = values.TakeLast(2).Average(); var declined = group.GradingSystem == GradingSystem.Grades1To6 ? recent - previous >= 1.0 : previous - recent >= 3.0; if (declined) Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, "Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}", AlertSeverity.Medium)); } var latestReport = groupReportGrades .Where(r => r.StudentId == student.Id) .OrderByDescending(r => r.UpdatedAt).FirstOrDefault(); var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue; if (int.TryParse(effective, out var reportValue) && (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4)) Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName, "Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}", AlertSeverity.High)); } } } // ── Konfigurierbare Kacheln (9.6) ──────────────────────────────────────── private void LoadDashboardCards() { DashboardCards.Clear(); foreach (var setting in _dashboardSettings.Load()) { var option = new DashboardCardOption(setting.Key, CardTitle(setting.Key), setting.IsVisible); option.OnVisibilityChanged = SaveAndApplyCardLayout; DashboardCards.Add(option); } ApplyCardLayout(); } private static string CardTitle(string key) => key switch { "today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender", "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", "missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key, }; private void ApplyCardLayout() { var visibleIndex = 0; foreach (var card in DashboardCards) { var index = card.IsVisible ? visibleIndex++ : 0; card.Row = index / 2; card.Column = index % 2; } } private void SaveAndApplyCardLayout() { ApplyCardLayout(); _dashboardSettings.Save(DashboardCards.Select((c, i) => new DashboardCardSetting { Key = c.Key, IsVisible = c.IsVisible, Order = i })); } [RelayCommand] private void ToggleDashboardSettings() => IsDashboardSettingsOpen = !IsDashboardSettingsOpen; [RelayCommand] private void MoveCardUp(DashboardCardOption? card) { if (card is null) return; var index = DashboardCards.IndexOf(card); if (index <= 0) return; DashboardCards.Move(index, index - 1); SaveAndApplyCardLayout(); } [RelayCommand] private void MoveCardDown(DashboardCardOption? card) { if (card is null) return; var index = DashboardCards.IndexOf(card); if (index < 0 || index >= DashboardCards.Count - 1) return; DashboardCards.Move(index, index + 1); SaveAndApplyCardLayout(); } [RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item) { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } [RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } [RelayCommand] private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item) { if (item is null || OnAddMissingTeachingTime is null) return; await OnAddMissingTeachingTime(item); LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today)); UpdateDashboardSummary(); } private void LoadOpenExcuses(List groups, DateOnly today) { OpenExcuses.Clear(); var cutoff = today.AddDays(-OpenExcuseMaxAgeDays); var items = new List(); foreach (var g in groups) { foreach (var session in _participationSessions.GetByGroup(g.Id).Where(s => s.Date >= cutoff && s.Date <= today)) { foreach (var entry in _participationEntries.GetBySession(session.Id) .Where(e => e.Attendance == AttendanceStatus.ExcusePending)) { var student = _students.GetById(entry.StudentId); if (student is null) continue; var item = new OpenExcuseItem(session.Id, entry.StudentId, student.FullName, g.Name, session.Date); item.OnResolve = ResolveExcuse; items.Add(item); } } } foreach (var item in items.OrderBy(i => i.Date)) OpenExcuses.Add(item); } private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status) { var entry = _participationEntries.GetBySessionAndStudent(item.SessionId, item.StudentId); if (entry is null) return; entry.Attendance = status; _participationEntries.Save(entry); OpenExcuses.Remove(item); UpdateDashboardSummary(); } private void LoadCalendar() { CalendarDays.Clear(); var today = DateOnly.FromDateTime(DateTime.Today); // Rasterstart: Montag der Woche, die den 1. des Monats enthält – 6 Wochen (42 Tage) Raster. var firstOfMonth = CalendarMonth; var mondayOffset = ((int)firstOfMonth.DayOfWeek + 6) % 7; // Montag=0 ... Sonntag=6 var gridStart = firstOfMonth.AddDays(-mondayOffset); var gridEnd = gridStart.AddDays(41); var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()); var byDay = new Dictionary(); DayAgg Agg(DateOnly d) { if (!byDay.TryGetValue(d, out var agg)) byDay[d] = agg = new DayAgg(); return agg; } foreach (var g in groups) { foreach (var lesson in _lessons.GetByGroupAndRange(g.Id, gridStart, gridEnd)) { var agg = Agg(lesson.Date); agg.HasLesson = true; if (g.IsOwnClass) agg.IsOwnClassDay = true; agg.Details.Add(new CalendarEventItem(CalendarEventKind.Lesson, lesson.Date, g.Name, lesson.Topic, g.Id)); } foreach (var exam in _exams.GetByGroup(g.Id).Where(e => e.Date >= gridStart && e.Date <= gridEnd)) { var agg = Agg(exam.Date); agg.HasExam = true; if (g.IsOwnClass) agg.IsOwnClassDay = true; agg.Details.Add(new CalendarEventItem(CalendarEventKind.Exam, exam.Date, exam.Title, g.Name, g.Id)); } // Sitzungen, die über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstanden sind // (LessonId gesetzt), bekommen bewusst keinen eigenen Kalendereintrag — die Stunde // selbst ist an diesem Tag/dieser Gruppe schon als Lesson-Termin gelistet, ein // zweiter Eintrag für dieselbe Unterrichtsstunde wäre eine Dopplung. foreach (var session in _participationSessions.GetByGroup(g.Id) .Where(s => s.LessonId is null && s.Date >= gridStart && s.Date <= gridEnd)) { var agg = Agg(session.Date); agg.HasSession = true; if (g.IsOwnClass) agg.IsOwnClassDay = true; agg.Details.Add(new CalendarEventItem(CalendarEventKind.ParticipationSession, session.Date, g.Name, session.Comment ?? "", g.Id)); } } // Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an // keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem // betroffenen Kalendertag. if (_annualPlanEvents is not null) { foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd) .Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))) { var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate; var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate; for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1)) { var agg = Agg(date); agg.HasAnnualPlanEvent = true; agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date, annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null, annualEvent.Description)); } } } // "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung // existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den // aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht. // Dieselbe Projektion wie bei den ungeplanten Stunden (LoadUnplannedLessons), nur über den // gesamten Kalenderraster statt nur die nächsten Tage. var publicHolidayDatesForGrid = Enumerable.Range(gridStart.Year, gridEnd.Year - gridStart.Year + 1) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .Select(h => h.Date).ToHashSet(); var schoolHolidaysForGrid = _schoolHolidays.GetAll(); foreach (var g in groups.Where(g => g.IsOwnClass)) { var slots = _timetableSlots.GetByGroup(g.Id); if (slots.Count == 0) continue; for (var date = gridStart; date <= gridEnd; date = date.AddDays(1)) { if (IsFreeDay(date, schoolHolidaysForGrid, publicHolidayDatesForGrid)) continue; var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList(); if (daySlots.Count == 0) continue; var cancelledPeriods = _substitutions.GetByDate(date) .Where(s => s.Kind == SubstitutionKind.Cancelled) .Select(s => s.PeriodNumber).ToHashSet(); if (daySlots.All(s => cancelledPeriods.Contains(s.PeriodNumber))) continue; Agg(date).IsOwnClassDay = true; } } for (var i = 0; i < 42; i++) { var date = gridStart.AddDays(i); byDay.TryGetValue(date, out var agg); CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today, agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false, agg?.HasAnnualPlanEvent ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? [])); } SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth) ?? CalendarDays.First(d => d.IsCurrentMonth)); } [RelayCommand] private void SelectCalendarDay(CalendarDayCell? day) { if (day is null) return; foreach (var cell in CalendarDays) cell.IsSelected = cell == day; SelectedCalendarDate = day.Date; SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De); SelectedDayEvents.Clear(); foreach (var item in day.Events) SelectedDayEvents.Add(item); SortCurrentGroups(day.Date); } private void SortCurrentGroups(DateOnly date) { var schoolHolidays = _schoolHolidays.GetAll(); var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State) .Select(h => h.Date).ToHashSet(); var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays); var cancelledPeriods = _substitutions.GetByDate(date) .Where(s => s.Kind == SubstitutionKind.Cancelled) .Select(s => s.PeriodNumber).ToHashSet(); foreach (var chip in CurrentGroups) { var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0; var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId) .Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber)); chip.IsOnSelectedDay = hasLesson || hasActiveSlot; } var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay) .ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); CurrentGroups.Clear(); foreach (var chip in sorted) CurrentGroups.Add(chip); } [RelayCommand] private void OpenCalendarEvent(CalendarEventItem? item) { if (item is null) return; if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information. if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId); else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit" } [RelayCommand] private void PrevMonth() { CalendarMonth = CalendarMonth.AddMonths(-1); OnPropertyChanged(nameof(CalendarMonthLabel)); LoadCalendar(); } [RelayCommand] private void NextMonth() { CalendarMonth = CalendarMonth.AddMonths(1); OnPropertyChanged(nameof(CalendarMonthLabel)); LoadCalendar(); } [RelayCommand] private void CalendarToday() { CalendarMonth = FirstOfMonth(DateTime.Today); OnPropertyChanged(nameof(CalendarMonthLabel)); LoadCalendar(); } [RelayCommand] private void OpenGroup(GroupChip? c) { if (c is not null) OnNavigateToGroup?.Invoke(c.GroupId); } [RelayCommand] private void OpenLesson(LessonItem? l) { if (l is not null) OnNavigateToLesson?.Invoke(l.GroupId); } [RelayCommand] private void OpenUpcomingDate(UpcomingDateItem? item) { if (item?.StudentId is Guid studentId) OnNavigateToStudent?.Invoke(studentId); else if (item?.GroupId is Guid groupId) { if (item.Kind == UpcomingDateKind.Exam) OnNavigateToExam?.Invoke(groupId); else OnNavigateToGroup?.Invoke(groupId); } } [RelayCommand] private void OpenCorrection(CorrectionProgressItem? item) { if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); } [RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item) { if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); } [RelayCommand] private void OpenAlert(DashboardAlertItem? item) { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } [RelayCommand] private void Refresh() => Load(); [RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false); [RelayCommand] private Task AddReminder() => AddTaskInternal(startAsReminder: true); private async Task AddTaskInternal(bool startAsReminder) { if (OnAddTask is null) return; var result = await OnAddTask(startAsReminder); if (result is null) return; _tasks.Save(result); Load(); } private class DayAgg { public bool HasLesson; public bool HasExam; public bool HasSession; public bool HasAnnualPlanEvent; public bool IsOwnClassDay; public List Details { get; } = []; } private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry) { string time; if (entry.IsAllDay) { time = entry.StartDate == entry.EndDate ? "Ganztägig" : $"{entry.StartDate:dd.MM.}–{entry.EndDate:dd.MM.yyyy} · ganztägig"; } else if (entry.StartDate == entry.EndDate) { time = entry.EndTime is { } end ? $"{entry.StartTime:HH\\:mm}–{end:HH\\:mm}" : $"{entry.StartTime:HH\\:mm}"; } else { time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}–" + $"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}"; } return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location } .Where(part => !string.IsNullOrWhiteSpace(part))); } } public sealed class DashboardWeatherWarningItem { public string Headline { get; } public string Description { get; } public string Instruction { get; } public string PeriodDisplay { get; } public string SeverityColor { get; } public DashboardWeatherWarningItem(WeatherWarning warning) { Headline = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline; Description = warning.Description; Instruction = warning.Instruction; PeriodDisplay = (warning.Onset, warning.Expires) switch { ({ } onset, { } expires) => $"{onset.ToLocalTime():dd.MM., HH:mm}–{expires.ToLocalTime():HH:mm} Uhr", ({ } onset, null) => $"ab {onset.ToLocalTime():dd.MM., HH:mm} Uhr", _ => "", }; SeverityColor = warning.Severity switch { "Extreme" => "#7E0023", "Severe" => "#D32F2F", "Moderate" => "#F59E0B", _ => "#FDD835", }; } } public class LessonItem { public Guid LessonId { get; set; } public Guid GroupId { get; set; } public string GroupName { get; set; } = ""; public string Topic { get; set; } = ""; public string TimeDisplay { get; set; } = ""; public string Room { get; set; } = ""; public bool HasRoom => !string.IsNullOrWhiteSpace(Room); } public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } } public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; public bool IsOnSelectedDay { get; set; } } // ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ──────────────────────── public partial class OpenExcuseItem : ObservableObject { public Guid SessionId { get; } public Guid StudentId { get; } public string StudentName { get; } public string GroupName { get; } public DateOnly Date { get; } public string DateDisplay { get; } public Action? OnResolve { get; set; } public OpenExcuseItem(Guid sessionId, Guid studentId, string studentName, string groupName, DateOnly date) { SessionId = sessionId; StudentId = studentId; StudentName = studentName; GroupName = groupName; Date = date; DateDisplay = date.ToString("dd.MM.yyyy"); } [RelayCommand] private void MarkExcused() => OnResolve?.Invoke(this, AttendanceStatus.Excused); [RelayCommand] private void MarkUnexcused() => OnResolve?.Invoke(this, AttendanceStatus.Unexcused); } // ── Fehlzeiten-Warnung (5.2.3) ──────────────────────────────────────────────── public class AttendanceWarningItem(Guid studentId, string studentName, double absenceRatePercent) { public Guid StudentId { get; } = studentId; public string StudentName { get; } = studentName; public double AbsenceRatePercent { get; } = absenceRatePercent; } public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount) { public int ExamCount { get; } = examCount; public string Label => $"KW {isoWeek} ({weekStart:dd.MM.}–{weekEnd:dd.MM.})"; public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren"; } public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd) { private static readonly CultureInfo De = new("de-DE"); public DateOnly Date { get; } = date; public TimeOnly WindowStart { get; } = windowStart; public TimeOnly WindowEnd { get; } = windowEnd; public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De); } // ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────────── public class SupportPlanDueItem { public Guid StudentId { get; } public string StudentName { get; } public string Title { get; } public string ReviewDateDisplay { get; } public bool IsOverdue { get; } public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today) { StudentId = studentId; StudentName = studentName; Title = title; ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy"); IsOverdue = reviewDate < today; } } public partial class CalendarDayCell : ObservableObject { [ObservableProperty] private bool _isSelected; public DateOnly Date { get; } public int DayNumber { get; } public bool IsCurrentMonth { get; } public bool IsToday { get; } public bool HasLesson { get; } public bool HasExam { get; } public bool HasSession { get; } public bool HasAnnualPlanEvent { get; } public bool IsOwnClassDay { get; } public string Tooltip { get; } public IReadOnlyList Events { get; } internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday, bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent, bool isOwnClassDay, List details) { Date = date; DayNumber = date.Day; IsCurrentMonth = isCurrentMonth; IsToday = isToday; HasLesson = hasLesson; HasExam = hasExam; HasSession = hasSession; HasAnnualPlanEvent = hasAnnualPlanEvent; IsOwnClassDay = isOwnClassDay; Events = details; Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") : string.Join("\n", details.Select(d => $"{d.KindLabel}: {d.Title}")); } } public enum CalendarEventKind { Lesson, Exam, ParticipationSession, AnnualPlan } public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title, string subtitle, Guid? groupId, string description = "") { public CalendarEventKind Kind { get; } = kind; public DateOnly Date { get; } = date; public string Title { get; } = title; public string Subtitle { get; } = subtitle; public Guid? GroupId { get; } = groupId; public string Description { get; } = description; public string KindLabel => Kind switch { CalendarEventKind.Exam => "Klausur", CalendarEventKind.ParticipationSession => "Sitzung", CalendarEventKind.AnnualPlan => "Jahresplan", _ => "Unterricht" }; } public enum UpcomingDateKind { Exam, SupportPlan, Deadline } public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, string title, string subtitle, Guid? groupId, Guid? studentId, DateOnly today) { public UpcomingDateKind Kind { get; } = kind; public DateOnly Date { get; } = date; public string Title { get; } = title; public string Subtitle { get; } = subtitle; public Guid? GroupId { get; } = groupId; public Guid? StudentId { get; } = studentId; public bool IsOverdue { get; } = date < today; public string DateDisplay => Date.ToString("dd.MM."); public string KindLabel => Kind switch { UpcomingDateKind.Exam => "Klausur", UpcomingDateKind.SupportPlan => "Förderplan", _ => "Frist", }; } public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName, DateOnly date, int completed, int total, DateOnly today) { public Guid ExamId { get; } = examId; public Guid GroupId { get; } = groupId; public string Title { get; } = title; public string GroupName { get; } = groupName; public DateOnly Date { get; } = date; public int Completed { get; } = completed; public int Total { get; } = total; public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total); public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet"; public string DateDisplay => Date.ToString("dd.MM.yyyy"); public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total; } public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today) { public Guid GroupId { get; } = groupId; public string GroupName { get; } = groupName; public DateOnly Date { get; } = date; public int PeriodNumber { get; } = periodNumber; public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM."); public string Display => $"{GroupName} · {PeriodNumber}. Stunde"; } public enum AlertSeverity { Medium, High } public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName, string kindLabel, string detail, AlertSeverity severity) { public Guid StudentId { get; } = studentId; public Guid? GroupId { get; } = groupId; public string StudentName { get; } = studentName; public string KindLabel { get; } = kindLabel; public string Detail { get; } = detail; public AlertSeverity Severity { get; } = severity; public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B"; } public partial class DashboardCardOption : ObservableObject { [ObservableProperty] private bool _isVisible; [ObservableProperty] private bool _isEmpty; [ObservableProperty] private int _row; [ObservableProperty] private int _column; public string Key { get; } public string Title { get; } public bool HideWhenEmpty { get; } public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty); public Action? OnVisibilityChanged { get; set; } public DashboardCardOption(string key, string title, bool isVisible) { Key = key; Title = title; HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned" or "alerts" or "attendance" or "support"; _isVisible = isVisible; } partial void OnIsVisibleChanged(bool value) { OnPropertyChanged(nameof(EffectiveIsVisible)); OnVisibilityChanged?.Invoke(); } partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible)); }