using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using System.Collections.ObjectModel; using System.Globalization; namespace LehrerApp.Desktop.ViewModels; 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 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 const int OpenExcuseMaxAgeDays = 21; private const int SupportPlanDueWithinDays = 14; [ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); 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 SupportPlanReviews { 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; } public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions, IParticipationRepository participationEntries, IStudentRepository students, IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy) { _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _participationSessions = participationSessions; _participationEntries = participationEntries; _students = students; _documentation = documentation; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _attendanceBalance = attendanceBalance; _sy = sy; Load(); } 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"; 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 }); CurrentGroups.Clear(); foreach (var g in groups.Values.OrderBy(g => g.Name)) 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); LoadSupportPlanReviews(today); } // ── 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); var items = new List(); foreach (var student in _students.GetAll()) { var entries = _participationEntries.GetByStudent(student.Id) .Select(e => _participationSessions.GetById(e.SessionId) is { } session ? ((DateOnly?)session.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.ExceedsThreshold) items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); } foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) AttendanceWarnings.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)); } } [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); } 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); } 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($"Unterricht: {g.Name}" + (string.IsNullOrWhiteSpace(lesson.Topic) ? "" : $" – {lesson.Topic}")); } 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($"Klausur: {exam.Title} ({g.Name})"); } } 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?.IsOwnClassDay ?? false, agg?.Details ?? [])); } } [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 Refresh() => Load(); private class DayAgg { public bool HasLesson; public bool HasExam; public bool IsOwnClassDay; public List Details { get; } = []; } } 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 class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { 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; } // ── 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 class CalendarDayCell { public int DayNumber { get; } public bool IsCurrentMonth { get; } public bool IsToday { get; } public bool HasLesson { get; } public bool HasExam { get; } public bool IsOwnClassDay { get; } public string Tooltip { get; } internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday, bool hasLesson, bool hasExam, bool isOwnClassDay, List details) { DayNumber = date.Day; IsCurrentMonth = isCurrentMonth; IsToday = isToday; HasLesson = hasLesson; HasExam = hasExam; IsOwnClassDay = isOwnClassDay; Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") : string.Join("\n", details); } }