Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.
LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
365 lines
17 KiB
C#
365 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 LehrerApp.Desktop.ViewModels;
|
|
using LehrerApp.Desktop.ViewModels.Workload;
|
|
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 IExamResultRepository _examResults;
|
|
private readonly IParticipationSessionRepository _sessions;
|
|
private readonly IParticipationRepository _entries;
|
|
private readonly IStudentRepository _students;
|
|
private readonly IDocumentationRepository _documentation;
|
|
private readonly IWorkTaskRepository _tasks;
|
|
private readonly IGroupRepository _groups;
|
|
private readonly AttendanceBalanceService _attendanceBalance;
|
|
private readonly GradingService _grading;
|
|
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 bool _hasYearComparison;
|
|
[ObservableProperty] private string _yearComparisonLabel = "";
|
|
[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; }
|
|
|
|
/// Anders als OnNavigateToTab kein Tab innerhalb dieser Detailansicht, sondern ein Sprung in
|
|
/// den eigenständigen Top-Level-Aufgabenbereich (MainWindowViewModel.NavigateToWorkload) — von
|
|
/// hier aus nicht direkt aufrufbar (keine ViewModel-zu-ViewModel-Referenz, siehe
|
|
/// GroupDetailView.axaml.cs, gleiches Muster wie LessonViewerDialog/TeachingModeWindow).
|
|
public Action? OnNavigateToWorkload { 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);
|
|
[RelayCommand] private void NavigateToWorkload() => OnNavigateToWorkload?.Invoke();
|
|
|
|
// ── Anstehende Aufgaben für diese Klasse (Nutzer-Feedback: pädagogische Erinnerungen/Aufgaben
|
|
// sollen "gleichberechtigt" auch im Kurs-Dashboard stehen, nicht nur im Hauptdashboard) ───────
|
|
private const int GroupTasksMaxCount = 5;
|
|
public ObservableCollection<GroupTaskItem> GroupTasks { get; } = [];
|
|
public bool HasGroupTasks => GroupTasks.Count > 0;
|
|
|
|
/// Öffnet den Aufgaben-Dialog mit dieser Gruppe vorbelegt (Nutzer-Feedback: direkter
|
|
/// Anlege-Einstieg aus dem Kurs-Dashboard, statt erst über "Zu den Aufgaben" springen zu
|
|
/// müssen). Gleiches View-Code-Behind-Delegate-Muster wie OnNavigateToWorkload.
|
|
public Func<Guid, Task<WorkTask?>>? OnAddGroupTask { get; set; }
|
|
|
|
[RelayCommand]
|
|
private async Task AddGroupTask()
|
|
{
|
|
if (OnAddGroupTask is null) return;
|
|
var result = await OnAddGroupTask(_groupId);
|
|
if (result is null) return;
|
|
_tasks.Save(result);
|
|
LoadGroupTasks(DateOnly.FromDateTime(DateTime.Today));
|
|
}
|
|
|
|
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
|
|
IExamResultRepository examResults, IParticipationSessionRepository sessions,
|
|
IParticipationRepository entries, IStudentRepository students,
|
|
IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups,
|
|
AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear)
|
|
{
|
|
_lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions;
|
|
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
|
|
_groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _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);
|
|
LoadYearComparison();
|
|
LoadParticipationHint(today);
|
|
LoadOpenHomeworkCheck(today);
|
|
LoadOpenExcuses(today);
|
|
LoadDraftDocumentationCount();
|
|
LoadAttendanceNotices(today);
|
|
LoadMissingHomework();
|
|
LoadGroupTasks(today);
|
|
}
|
|
|
|
private void LoadNextLesson(DateOnly today)
|
|
{
|
|
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
|
.Where(l => l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled))
|
|
.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}";
|
|
}
|
|
|
|
// ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ──────────────────
|
|
//
|
|
// Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe
|
|
// hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds
|
|
// hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn
|
|
// im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich,
|
|
// auch wenn eine Verknüpfung besteht.
|
|
|
|
private void LoadYearComparison()
|
|
{
|
|
var group = _groups.GetById(_groupId);
|
|
var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null;
|
|
if (previous is null) { HasYearComparison = false; return; }
|
|
|
|
var current = ComputeExamAverage(_groupId);
|
|
YearComparisonLabel = current is null
|
|
? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur."
|
|
: $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " +
|
|
$"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)";
|
|
HasYearComparison = true;
|
|
}
|
|
|
|
private (double Average, int Count)? ComputeExamAverage(Guid groupId)
|
|
{
|
|
var grades = _exams.GetByGroup(groupId)
|
|
.SelectMany(e => _examResults.GetByExam(e.Id))
|
|
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
|
.Select(r => (Grade: r.Grade!, Weight: 1.0))
|
|
.ToList();
|
|
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
|
|
}
|
|
|
|
/// 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))
|
|
.Where(l => l.Status != LessonStatus.Cancelled)
|
|
.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));
|
|
}
|
|
|
|
private void LoadGroupTasks(DateOnly today)
|
|
{
|
|
GroupTasks.Clear();
|
|
foreach (var t in _tasks.GetByGroup(_groupId)
|
|
.Where(t => t.Status != WorkTaskStatus.Done)
|
|
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(GroupTasksMaxCount))
|
|
GroupTasks.Add(new GroupTaskItem(t.Title, t.Kind == TaskKind.Reminder,
|
|
t.DueDate?.ToString("dd.MM.yyyy") ?? "",
|
|
t.DueDate is { } d && d < today, TaskPriorityDisplay.ColorHex(t.Priority),
|
|
t.Priority == TaskPriority.High));
|
|
OnPropertyChanged(nameof(HasGroupTasks));
|
|
}
|
|
}
|
|
|
|
public sealed class MissingHomeworkItem(string studentName, string statusLabel)
|
|
{
|
|
public string StudentName { get; } = studentName;
|
|
public string StatusLabel { get; } = statusLabel;
|
|
}
|
|
|
|
public sealed class GroupTaskItem(string title, bool isReminder, string dueDateDisplay, bool isOverdue,
|
|
string priorityColorHex, bool isHighPriority)
|
|
{
|
|
public string Title { get; } = title;
|
|
public bool IsReminder { get; } = isReminder;
|
|
public string DueDateDisplay { get; } = dueDateDisplay;
|
|
public bool IsOverdue { get; } = isOverdue;
|
|
public string PriorityColorHex { get; } = priorityColorHex;
|
|
public bool IsHighPriority { get; } = isHighPriority;
|
|
}
|