TodaysLessons löst je Stunde den Raum über den passenden TimetableSlot auf und die Uhrzeit aus Lesson.StartTime bzw. dem Stundenraster - gleiche Quellen wie im Verlaufsplan-Editor und Stundenplan. Bewusst nicht dupliziert: die Vertretung/Ausfall-Logik der Stundenplan-eigenen "Heute"-Ansicht bleibt dort, das Dashboard zeigt nur die einfache geplante Stunde. Klick auf eine Stunde springt in die Mitarbeitserfassung der Gruppe - bewusst anderes Ziel als der bestehende Stundenplan-Sprung (dort "Planung"), da vom Dashboard aus morgens eher die Mitarbeitserfassung naheliegt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
394 lines
17 KiB
C#
394 lines
17 KiB
C#
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<LessonItem> TodaysLessons { get; } = [];
|
||
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
|
||
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
|
||
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
|
||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
||
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
|
||
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
||
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
|
||
// Navigation-Callback – wird von App.axaml.cs verdrahtet
|
||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||
public Action<Guid>? 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<Guid>? 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<AttendanceWarningItem>();
|
||
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<LearningGroup> groups, DateOnly today)
|
||
{
|
||
OpenExcuses.Clear();
|
||
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
|
||
|
||
var items = new List<OpenExcuseItem>();
|
||
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<DateOnly, DayAgg>();
|
||
|
||
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<string> 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<OpenExcuseItem, AttendanceStatus>? 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<string> 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);
|
||
}
|
||
}
|