Files
LehrerApp/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
T
adminandClaude Sonnet 5 d987b93315 Schülerdokumentation: Einträge, Fehlzeitenbilanz, Datenschutz (Kapitel 5)
Dokumentationseinträge mit Typwahl (Gespräch, Vorkommnis, Förderplan,
Fehlzeit, Elternanruf, Elternbrief), vertrauliche Einträge nur nach
Bestätigung sichtbar, weiche Löschung mit Nachvollziehbarkeit. Fehlzeiten
als Auswertung des bestehenden Anwesenheits-Trackings statt zweiter
Erfassung, mit Schwellenwert-Warnung im Schülerdetail und Dashboard.
Förderplan-Wiedervorlage als Dashboard-Karte. Datenschutz: Löschfristen
mit manueller Bereinigung und DSGVO-Art.-15-Datenauskunft als Export.

Auf Nutzer-Feedback hin ergänzt: Elternanruf mit begleitendem
Gesprächsprotokoll-Dialog (Punkte abhaken, Eindrücke festhalten),
Elternbrief mit Versand-/Rückmeldungs-Tracking, Datei-Anhänge über
LiteDBs Dateispeicher, frei vergebbare Labels zur Nachverfolgung mit
Dringlichkeits-Farbcodierung, sowie eine sichtbare Farblegende für das
bestehende Notenentwicklungs-Diagramm. Dabei einen Absturz behoben:
leere Textfelder lieferten über das Binding null statt "", was beim
Speichern eine NullReferenceException auslöste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:49:03 +02:00

360 lines
15 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 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; }
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
IParticipationRepository participationEntries, IStudentRepository students,
IDocumentationRepository documentation, AttendanceBalanceService attendanceBalance, SchoolYearService sy)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_participationSessions = participationSessions; _participationEntries = participationEntries;
_students = students; _documentation = documentation; _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))
TodaysLessons.Add(new() { GroupName = g.Name, Topic = l.Topic });
}
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 Refresh() => Load();
private class DayAgg
{
public bool HasLesson;
public bool HasExam;
public bool IsOwnClassDay;
public List<string> Details { get; } = [];
}
}
public class LessonItem { public string GroupName { get; set; } = ""; public string Topic { get; set; } = ""; }
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);
}
}