Files
LehrerApp/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
T
adminandClaude Sonnet 5 f8dd567ef8 Dashboard 9.1/9.2: Uhrzeit/Raum bei heutigen Stunden + Absprung zur Mitarbeit
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>
2026-08-16 23:54:52 +02:00

394 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}