Files
LehrerApp/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
T

720 lines
32 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 IExamResultRepository _examResults;
private readonly IGradeRepository _grades;
private readonly IReportGradeRepository _reportGrades;
private readonly IGroupMembershipRepository _memberships;
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 readonly DashboardSettingsService _dashboardSettings;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
private const int UpcomingWithinDays = 30;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private bool _isDashboardSettingsOpen;
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 ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { 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 Action<Guid>? OnNavigateToExam { get; set; }
public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks");
public DashboardCardOption CalendarCard => Card("calendar");
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections");
public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups");
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
IParticipationRepository participationEntries, IStudentRepository students,
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
_participationSessions = participationSessions; _participationEntries = participationEntries;
_students = students; _documentation = documentation;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
LoadDashboardCards();
Load();
}
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
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);
LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today);
LoadAlerts(groups, 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));
}
}
// ── Anstehende Termine (9.3) ─────────────────────────────────────────────
private void LoadUpcomingDates(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
UpcomingDates.Clear();
var dueBy = today.AddDays(UpcomingWithinDays);
var items = new List<UpcomingDateItem>();
foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Date >= today && e.Date <= dueBy && e.Status == ExamStatus.Planned))
items.Add(new UpcomingDateItem(UpcomingDateKind.Exam, exam.Date, exam.Title,
group.Name, group.Id, null, today));
foreach (var task in _tasks.GetByStatus(WorkTaskStatus.Open)
.Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress))
.Where(t => t.DueDate.HasValue && t.DueDate.Value <= dueBy))
items.Add(new UpcomingDateItem(UpcomingDateKind.Deadline, task.DueDate!.Value,
task.Title, task.GroupId is Guid groupId && groups.TryGetValue(groupId, out var group)
? group.Name : "Aufgabe", task.GroupId, null, today));
foreach (var doc in _documentation.GetAll()
.Where(d => d.Type == DocumentationType.SupportPlan
&& d.SupportData is { Status: SupportStatus.Active, ReviewDate: not null }
&& d.SupportData.ReviewDate.Value <= dueBy))
{
var student = _students.GetById(doc.StudentId);
if (student is not null)
items.Add(new UpcomingDateItem(UpcomingDateKind.SupportPlan,
doc.SupportData!.ReviewDate!.Value, doc.Title, student.FullName,
doc.GroupId, doc.StudentId, today));
}
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.Title).Take(8))
UpcomingDates.Add(item);
}
// ── Offene Korrekturen (9.4) ─────────────────────────────────────────────
private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
OpenCorrections.Clear();
foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
.OrderBy(e => e.Date))
{
var expected = _memberships.GetByGroup(group.Id)
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
var evaluated = _examResults.GetByExam(exam.Id)
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
group.Name, exam.Date, Math.Min(evaluated, expected), expected, today));
}
}
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
Alerts.Clear();
foreach (var warning in AttendanceWarnings)
Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName,
"Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High));
foreach (var group in groups.Values)
{
var groupReportGrades = _reportGrades.GetByGroup(group.Id);
foreach (var membership in _memberships.GetByGroup(group.Id)
.Where(m => GroupMembershipService.IsActiveOn(m, today)))
{
var student = _students.GetById(membership.StudentId);
if (student is null) continue;
var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
.OrderBy(g => g.Date)
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null)
.Where(v => v.HasValue).Select(v => v!.Value).ToList();
if (values.Count >= 4)
{
var previous = values.TakeLast(4).Take(2).Average();
var recent = values.TakeLast(2).Average();
var declined = group.GradingSystem == GradingSystem.Grades1To6
? recent - previous >= 1.0
: previous - recent >= 3.0;
if (declined)
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
"Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
AlertSeverity.Medium));
}
var latestReport = groupReportGrades
.Where(r => r.StudentId == student.Id)
.OrderByDescending(r => r.UpdatedAt).FirstOrDefault();
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
if (int.TryParse(effective, out var reportValue)
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
"Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}",
AlertSeverity.High));
}
}
}
// ── Konfigurierbare Kacheln (9.6) ────────────────────────────────────────
private void LoadDashboardCards()
{
DashboardCards.Clear();
foreach (var setting in _dashboardSettings.Load())
{
var option = new DashboardCardOption(setting.Key, CardTitle(setting.Key), setting.IsVisible);
option.OnVisibilityChanged = SaveAndApplyCardLayout;
DashboardCards.Add(option);
}
ApplyCardLayout();
}
private static string CardTitle(string key) => key switch
{
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key,
};
private void ApplyCardLayout()
{
var visibleIndex = 0;
foreach (var card in DashboardCards)
{
var index = card.IsVisible ? visibleIndex++ : 0;
card.Row = index / 2;
card.Column = index % 2;
}
}
private void SaveAndApplyCardLayout()
{
ApplyCardLayout();
_dashboardSettings.Save(DashboardCards.Select((c, i) => new DashboardCardSetting
{ Key = c.Key, IsVisible = c.IsVisible, Order = i }));
}
[RelayCommand] private void ToggleDashboardSettings() =>
IsDashboardSettingsOpen = !IsDashboardSettingsOpen;
[RelayCommand]
private void MoveCardUp(DashboardCardOption? card)
{
if (card is null) return;
var index = DashboardCards.IndexOf(card);
if (index <= 0) return;
DashboardCards.Move(index, index - 1);
SaveAndApplyCardLayout();
}
[RelayCommand]
private void MoveCardDown(DashboardCardOption? card)
{
if (card is null) return;
var index = DashboardCards.IndexOf(card);
if (index < 0 || index >= DashboardCards.Count - 1) return;
DashboardCards.Move(index, index + 1);
SaveAndApplyCardLayout();
}
[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(new CalendarEventItem(CalendarEventKind.Lesson, lesson.Date,
g.Name, lesson.Topic, g.Id));
}
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(new CalendarEventItem(CalendarEventKind.Exam, exam.Date,
exam.Title, g.Name, g.Id));
}
}
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 ?? []));
}
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth));
}
[RelayCommand]
private void SelectCalendarDay(CalendarDayCell? day)
{
if (day is null) return;
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
SelectedDayEvents.Clear();
foreach (var item in day.Events) SelectedDayEvents.Add(item);
}
[RelayCommand]
private void OpenCalendarEvent(CalendarEventItem? item)
{
if (item is null) return;
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId);
else OnNavigateToLesson?.Invoke(item.GroupId);
}
[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 OpenUpcomingDate(UpcomingDateItem? item)
{
if (item?.StudentId is Guid studentId) OnNavigateToStudent?.Invoke(studentId);
else if (item?.GroupId is Guid groupId)
{
if (item.Kind == UpcomingDateKind.Exam) OnNavigateToExam?.Invoke(groupId);
else OnNavigateToGroup?.Invoke(groupId);
}
}
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load();
private class DayAgg
{
public bool HasLesson;
public bool HasExam;
public bool IsOwnClassDay;
public List<CalendarEventItem> 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 partial class CalendarDayCell : ObservableObject
{
[ObservableProperty] private bool _isSelected;
public DateOnly Date { get; }
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; }
public IReadOnlyList<CalendarEventItem> Events { get; }
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
bool hasLesson, bool hasExam, bool isOwnClassDay, List<CalendarEventItem> details)
{
Date = date;
DayNumber = date.Day;
IsCurrentMonth = isCurrentMonth;
IsToday = isToday;
HasLesson = hasLesson;
HasExam = hasExam;
IsOwnClassDay = isOwnClassDay;
Events = details;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
: string.Join("\n", details.Select(d => $"{d.KindLabel}: {d.Title}"));
}
}
public enum CalendarEventKind { Lesson, Exam }
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
string subtitle, Guid groupId)
{
public CalendarEventKind Kind { get; } = kind;
public DateOnly Date { get; } = date;
public string Title { get; } = title;
public string Subtitle { get; } = subtitle;
public Guid GroupId { get; } = groupId;
public string KindLabel => Kind == CalendarEventKind.Exam ? "Klausur" : "Unterricht";
}
public enum UpcomingDateKind { Exam, SupportPlan, Deadline }
public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, string title,
string subtitle, Guid? groupId, Guid? studentId, DateOnly today)
{
public UpcomingDateKind Kind { get; } = kind;
public DateOnly Date { get; } = date;
public string Title { get; } = title;
public string Subtitle { get; } = subtitle;
public Guid? GroupId { get; } = groupId;
public Guid? StudentId { get; } = studentId;
public bool IsOverdue { get; } = date < today;
public string DateDisplay => Date.ToString("dd.MM.");
public string KindLabel => Kind switch
{
UpcomingDateKind.Exam => "Klausur",
UpcomingDateKind.SupportPlan => "Förderplan",
_ => "Frist",
};
}
public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName,
DateOnly date, int completed, int total, DateOnly today)
{
public Guid ExamId { get; } = examId;
public Guid GroupId { get; } = groupId;
public string Title { get; } = title;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int Completed { get; } = completed;
public int Total { get; } = total;
public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total);
public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet";
public string DateDisplay => Date.ToString("dd.MM.yyyy");
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
}
public enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
string kindLabel, string detail, AlertSeverity severity)
{
public Guid StudentId { get; } = studentId;
public Guid? GroupId { get; } = groupId;
public string StudentName { get; } = studentName;
public string KindLabel { get; } = kindLabel;
public string Detail { get; } = detail;
public AlertSeverity Severity { get; } = severity;
public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B";
}
public partial class DashboardCardOption : ObservableObject
{
[ObservableProperty] private bool _isVisible;
[ObservableProperty] private int _row;
[ObservableProperty] private int _column;
public string Key { get; }
public string Title { get; }
public Action? OnVisibilityChanged { get; set; }
public DashboardCardOption(string key, string title, bool isVisible)
{
Key = key;
Title = title;
_isVisible = isVisible;
}
partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke();
}