Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
T
adminandClaude Sonnet 5 52a1d45539 Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes
Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht
gemachter Hausaufgabe aus der letzten Mitarbeitssitzung.

Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von
reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse
machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard,
läuft nicht in die Zeitauswertung ein.

Drei Bugfixes aus Nutzer-Feedback:
- Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung
  erzeugen" mit einer Mitarbeitssitzung verknüpft war.
- Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei
  echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an.
- Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag
  eine Lesson existierte, statt schon laut Stundenplan.

Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift
(Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante
ausweichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:17:18 +02:00

270 lines
12 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Tab: Übersicht — kompakte "Was steht an"-Zusammenfassung eines Kurses ────
/// <summary>
/// Zieht keine eigenen neuen Daten — jede Karte liest nur, was die anderen Tabs (Planung,
/// Klausuren, Mitarbeit) ohnehin schon verwalten, und verweist per Klick dorthin. Bewusst kein
/// Ersatz für die Detail-Tabs, sondern ein schneller Überblick "was ist als nächstes dran /
/// was wurde vergessen".
/// </summary>
public partial class GroupOverviewViewModel : ObservableObject
{
private const int OpenExcuseMaxAgeDays = 21;
private const int HomeworkCheckLookbackDays = 120;
private const int NextLessonLookaheadDays = 90;
/// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine
/// hohe Fehlquote bewusst 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 DashboardViewModel.AttendanceMinSampleSize
/// (Hauptdashboard-Fehlzeiten-Warnung, 5.2.3) — dort dasselbe Problem, mittlerweile ebenfalls
/// gefixt.
private const int AttendanceMinSampleSize = 8;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _schoolYear;
private Guid _groupId;
private string _groupName = "";
[ObservableProperty] private bool _hasNextLesson;
[ObservableProperty] private string _nextLessonLabel = "";
[ObservableProperty] private bool _hasNextExam;
[ObservableProperty] private string _nextExamLabel = "";
[ObservableProperty] private string _participationHintLabel = "";
[ObservableProperty] private bool _participationHintIsStale;
[ObservableProperty] private bool _hasOpenHomeworkCheck;
[ObservableProperty] private string _openHomeworkCheckLabel = "";
[ObservableProperty] private int _draftDocumentationCount;
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public bool HasOpenExcuses => OpenExcuses.Count > 0;
[ObservableProperty] private string _missingHomeworkSessionLabel = "";
public ObservableCollection<MissingHomeworkItem> MissingHomeworkStudents { get; } = [];
public bool HasMissingHomework => MissingHomeworkStudents.Count > 0;
/// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
/// siehe <see cref="AttendanceMinSampleSize"/>.
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
public bool HasAttendanceNotices => AttendanceNotices.Count > 0;
/// Ein Delegate statt einem pro Karte, mit dem Ziel-Tab-Index von GroupDetailView.axaml als
/// Parameter (0 Übersicht, 1 Schüler, 2 Sitzpläne, 3 Mitarbeit, 4 Klausuren, 5 Noten,
/// 6 Planung, 7 Kompetenzen, 8 Dokumentation).
public Action<int>? OnNavigateToTab { get; set; }
[RelayCommand] private void NavigateToPlanning() => OnNavigateToTab?.Invoke(6);
[RelayCommand] private void NavigateToExams() => OnNavigateToTab?.Invoke(4);
[RelayCommand] private void NavigateToParticipation() => OnNavigateToTab?.Invoke(3);
[RelayCommand] private void NavigateToDocumentation() => OnNavigateToTab?.Invoke(8);
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
IParticipationSessionRepository sessions, IParticipationRepository entries,
IStudentRepository students, IDocumentationRepository documentation,
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
{
_lessons = lessons; _exams = exams; _sessions = sessions;
_entries = entries; _students = students; _documentation = documentation;
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
}
public void Initialize(Guid groupId, string groupName)
{
_groupId = groupId;
_groupName = groupName;
Refresh();
}
public void Refresh()
{
var today = DateOnly.FromDateTime(DateTime.Today);
LoadNextLesson(today);
LoadNextExam(today);
LoadParticipationHint(today);
LoadOpenHomeworkCheck(today);
LoadOpenExcuses(today);
LoadDraftDocumentationCount();
LoadAttendanceNotices(today);
LoadMissingHomework();
}
private void LoadNextLesson(DateOnly today)
{
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
.Where(l => l.Status == LessonStatus.Planned)
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
.FirstOrDefault();
HasNextLesson = next is not null;
NextLessonLabel = next is null ? ""
: string.IsNullOrWhiteSpace(next.Topic)
? next.Date.ToString("dd.MM.yyyy")
: $"{next.Date:dd.MM.yyyy} — {next.Topic}";
}
private void LoadNextExam(DateOnly today)
{
var next = _exams.GetByGroup(_groupId).Where(e => e.Date >= today).MinBy(e => e.Date);
HasNextExam = next is not null;
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
}
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
private void LoadParticipationHint(DateOnly today)
{
var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault();
if (last is null)
{
ParticipationHintLabel = "Noch keine Mitarbeitssitzung angelegt.";
ParticipationHintIsStale = false;
return;
}
var daysAgo = today.DayNumber - last.Date.DayNumber;
ParticipationHintLabel = daysAgo <= 0
? "Letzte Sitzung: heute."
: $"Letzte Sitzung: {last.Date:dd.MM.yyyy} (vor {daysAgo} Tag(en)).";
ParticipationHintIsStale = daysAgo > 14;
}
/// Dieselbe Erkennung wie das Stundenplan-Badge "Hausaufgabe kontrollieren"
/// (TimetableViewModel.HasUnhandledHomework) — bewusst nur die unmittelbar letzte Lesson,
/// nicht die gesamte Historie.
private void LoadOpenHomeworkCheck(DateOnly today)
{
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault();
var open = previous is not null && !string.IsNullOrWhiteSpace(previous.Homework)
&& !previous.HomeworkChecked && !previous.HomeworkCheckDismissed;
HasOpenHomeworkCheck = open;
OpenHomeworkCheckLabel = open
? $"Aus der Stunde vom {previous!.Date:dd.MM.yyyy}: {previous.Homework}"
: "";
}
private void LoadOpenExcuses(DateOnly today)
{
OpenExcuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>();
foreach (var session in _sessions.GetByGroup(_groupId).Where(s => s.Date >= cutoff && s.Date <= today))
{
foreach (var entry in _entries.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, _groupName, session.Date);
item.OnResolve = ResolveExcuse;
items.Add(item);
}
}
foreach (var item in items.OrderBy(i => i.Date))
OpenExcuses.Add(item);
OnPropertyChanged(nameof(HasOpenExcuses));
}
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
{
var entry = _entries.GetBySessionAndStudent(item.SessionId, item.StudentId);
if (entry is null) return;
entry.Attendance = status;
_entries.Save(entry);
OpenExcuses.Remove(item);
OnPropertyChanged(nameof(HasOpenExcuses));
}
/// Entwürfe (5.1.1: schnelle Notiz beim Sitzplan, noch nicht zu einem echten Dokumentations-
/// eintrag ausformuliert) dieser Gruppe — dieselbe Zählung wie das Badge in
/// GroupDocumentationTabViewModel.Load, hier nur ohne die vollständige Entwurfsliste.
private void LoadDraftDocumentationCount()
{
DraftDocumentationCount = _students.GetByGroup(_groupId)
.SelectMany(s => _documentation.GetByStudent(s.Id))
.Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
}
/// Dieselbe Mindeststichprobe wie DashboardViewModel.LoadAttendanceWarnings
/// (AttendanceMinSampleSize) — erst ab genug erfassten Terminen wird eine hohe Quote gemeldet,
/// damit ein Fehltag in der ersten Schulwoche nicht sofort als auffällig gilt.
private void LoadAttendanceNotices(DateOnly today)
{
AttendanceNotices.Clear();
var schoolYear = _schoolYear.CurrentSchoolYear(today);
var from = _schoolYear.SchoolYearStart(schoolYear);
var to = _schoolYear.SchoolYearEnd(schoolYear);
var entriesByStudent = new Dictionary<Guid, List<(DateOnly Date, AttendanceStatus? Status)>>();
foreach (var session in _sessions.GetByGroup(_groupId).Where(s => s.Date >= from && s.Date <= to))
{
foreach (var entry in _entries.GetBySession(session.Id))
{
if (!entriesByStudent.TryGetValue(entry.StudentId, out var list))
entriesByStudent[entry.StudentId] = list = [];
list.Add((session.Date, entry.Attendance));
}
}
var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetByGroup(_groupId))
{
if (!entriesByStudent.TryGetValue(student.Id, out var entries)) continue;
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))
AttendanceNotices.Add(item);
OnPropertyChanged(nameof(HasAttendanceNotices));
}
/// Schüler mit nicht gemachter Hausaufgabe aus der letzten Mitarbeitssitzung
/// (ParticipationEntry.HomeworkMissing, gepflegt über HomeworkDisplay.CountsAsMissing) —
/// bewusst nur die letzte Sitzung, nicht die gesamte Historie, analog zur
/// Hausaufgaben-Kontrolle in <see cref="LoadOpenHomeworkCheck"/>.
private void LoadMissingHomework()
{
MissingHomeworkStudents.Clear();
var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault();
if (last is not null)
{
MissingHomeworkSessionLabel = $"Aus der Sitzung vom {last.Date:dd.MM.yyyy}:";
foreach (var entry in _entries.GetBySession(last.Id).Where(e => e.HomeworkMissing))
{
var student = _students.GetById(entry.StudentId);
if (student is not null)
MissingHomeworkStudents.Add(new MissingHomeworkItem(student.FullName, HomeworkDisplay.Label(entry.Homework)));
}
}
OnPropertyChanged(nameof(HasMissingHomework));
}
}
public sealed class MissingHomeworkItem(string studentName, string statusLabel)
{
public string StudentName { get; } = studentName;
public string StatusLabel { get; } = statusLabel;
}