Fasst die sieben Kacheln, die tatsaechlich "wo muss ich reagieren" beantworten (Entschuldigungen, Fehlzeiten-Warnung, Foerderplan-Wiedervorlage, Korrekturen, ungeplante Stunden, Auffaelligkeiten, Unterrichtszeit-Nacherfassung), zu einer Karte "Handlungsbedarf" mit Filter-Chips zusammen statt sieben eigener Sichtbarkeits-Schalter. Neues gemeinsames Modell AttentionItem/AttentionGroup/AttentionAction traegt nur Anzeigedaten - die Datenbeschaffung bleibt unveraendert in den jeweiligen DashboardViewModel.LoadXxx-Methoden, die am Ende ein AttentionItem statt ihrer eigenen Item-Klasse erzeugen. RebuildAttention() gruppiert nach Art (feste Reihenfolge) und wendet Filter an, ohne die Repos erneut abzufragen. SupportPlanDueItem, CorrectionProgressItem, UnplannedLessonItem und DashboardAlertItem entfallen (nur dashboard-intern verwendet); OpenExcuseItem und AttendanceWarningItem bleiben bestehen, da GroupOverviewViewModel sie weiterhin nutzt. Fuenf Navigations-Commands wurden durch ein einziges OpenAttentionItemCommand ersetzt. Bewusste Verhaltensaenderung: LoadAlerts dupliziert Fehlzeiten-Ueberschreitungen nicht mehr in die Auffaelligkeiten-Gruppe, da dieselbe Zahl sonst zweimal in derselben Karte erschiene (vorher durch zwei getrennte Kacheln nicht sichtbar). Details und Testanpassungen siehe TODO.md, Abschnitt 9 (Dashboard).
1322 lines
64 KiB
C#
1322 lines
64 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.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 readonly ISchoolHolidayRepository _schoolHolidays;
|
||
private readonly PublicHolidayService _publicHolidays;
|
||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||
private readonly ISubstitutionEntryRepository _substitutions;
|
||
private readonly ITimeEntryRepository _timeEntries;
|
||
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
|
||
private readonly SchoolWeatherService? _schoolWeather;
|
||
private readonly UntisHubService _untisHub;
|
||
private readonly WebUntisIntegrationService _webUntis;
|
||
|
||
private const int OpenExcuseMaxAgeDays = 21;
|
||
private const int SupportPlanDueWithinDays = 14;
|
||
private const int UpcomingWithinDays = 30;
|
||
/// Nutzer-Feedback: "der zeitliche Vorgriff sollte sinnvoll sein" - heute + morgen ist knapp
|
||
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
|
||
/// um sich abends noch vorzubereiten.
|
||
private const int UnplannedLessonsLookaheadDays = 1;
|
||
/// Nutzer-Feedback: unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden
|
||
/// Schuljahr wird eine hohe Fehlquote 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 GroupOverviewViewModel.AttendanceMinSampleSize.
|
||
private const int AttendanceMinSampleSize = 8;
|
||
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
|
||
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
|
||
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
|
||
/// geht, sobald der nächste Schultag anbricht.
|
||
private const int MissingTeachingTimeLookbackDays = 14;
|
||
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
|
||
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
|
||
private const int MissingTeachingTimeTodayDelayMinutes = 30;
|
||
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
|
||
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
|
||
private const int TeachingWindowBufferBeforeMinutes = 15;
|
||
private const int TeachingWindowBufferAfterMinutes = 10;
|
||
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich
|
||
/// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
|
||
/// keine Vorschau auf das ganze Schuljahr.
|
||
private const int ExamLoadLookaheadDays = 60;
|
||
|
||
[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 DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
|
||
[ObservableProperty] private bool _isDashboardSettingsOpen;
|
||
[ObservableProperty] private bool _isWeatherPanelVisible;
|
||
[ObservableProperty] private string _weatherSummary = "";
|
||
[ObservableProperty] private string _weatherDetails = "";
|
||
[ObservableProperty] private string _weatherStatus = "";
|
||
[ObservableProperty] private bool _hasWeatherWarnings;
|
||
|
||
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<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
|
||
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
||
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
|
||
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
|
||
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
|
||
// Zusammengefasste "Handlungsbedarf"-Karte (vormals sieben eigene Kacheln/Collections:
|
||
// Entschuldigungen, Fehlzeiten, Förderplan, Korrekturen, ungeplante Stunden, Auffälligkeiten,
|
||
// Unterrichtszeit-Nacherfassung — siehe AttentionItem.cs). Attention traegt nur, was nach
|
||
// Filterung (AttentionFilters) tatsaechlich angezeigt wird; die Rohdaten je Art liegen in den
|
||
// privaten _xyzItems-Feldern und werden von RebuildAttention() neu zusammengesetzt, sobald sich
|
||
// Daten oder Filter aendern — ohne die Repos erneut abzufragen.
|
||
public ObservableCollection<AttentionGroup> Attention { get; } = [];
|
||
public ObservableCollection<AttentionFilterOption> AttentionFilters { get; } = [];
|
||
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
|
||
private readonly List<OpenExcuseItem> _excuses = [];
|
||
private readonly List<AttentionItem> _attendanceItems = [];
|
||
private readonly List<AttentionItem> _supportItems = [];
|
||
private readonly List<AttentionItem> _correctionItems = [];
|
||
private readonly List<AttentionItem> _unplannedItems = [];
|
||
private readonly List<AttentionItem> _alertItems = [];
|
||
private readonly List<AttentionItem> _missingTimeItems = [];
|
||
|
||
/// Reihenfolge und Überschriften der Gruppen in der zusammengefassten Handlungsbedarf-Karte —
|
||
/// übernimmt die frühere Kachel-Reihenfolge aus DashboardSettingsService.DefaultCardOrder,
|
||
/// damit sich für Nutzer nichts unvorhersehbar umsortiert.
|
||
private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder =
|
||
[
|
||
(AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"),
|
||
(AttentionKind.Excuse, "Offene Entschuldigungen"),
|
||
(AttentionKind.Correction, "Offene Korrekturen"),
|
||
(AttentionKind.Unplanned, "Ungeplante Stunden"),
|
||
(AttentionKind.Alert, "Auffälligkeiten"),
|
||
(AttentionKind.Attendance, "Fehlzeiten-Warnung"),
|
||
(AttentionKind.SupportPlan, "Förderplan-Wiedervorlage"),
|
||
];
|
||
|
||
// 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; }
|
||
// Direkter Anlege-Einstieg aus der "Offene Aufgaben"-Kachel (Nutzer-Feedback), statt erst über
|
||
// "Arbeitszeit" navigieren zu müssen — gleiches Dialog-Delegate-Muster wie im Kurs-Dashboard.
|
||
public Func<bool, Task<WorkTask?>>? OnAddTask { get; set; }
|
||
public Action<Guid>? OnNavigateToExam { get; set; }
|
||
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
||
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
|
||
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
|
||
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
|
||
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
|
||
|
||
public DashboardCardOption TodayCard => Card("today");
|
||
public DashboardCardOption TasksCard => Card("tasks");
|
||
public DashboardCardOption CalendarCard => Card("calendar");
|
||
public DashboardCardOption UpcomingCard => Card("upcoming");
|
||
public DashboardCardOption ExamLoadCard => Card("examload");
|
||
public DashboardCardOption GroupsCard => Card("groups");
|
||
public DashboardCardOption AttentionCard => Card("attention");
|
||
public int TodayLessonCount => TodaysLessons.Count;
|
||
public int OpenTaskCount => OpenTasks.Count;
|
||
public int UpcomingCount => UpcomingDates.Count;
|
||
public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count
|
||
+ _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count;
|
||
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
|
||
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
|
||
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
|
||
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
|
||
|
||
[ObservableProperty] private string _webUntisHealthLabel = "";
|
||
[ObservableProperty] private bool _isWebUntisHealthWarning;
|
||
[ObservableProperty] private bool _isWebUntisHealthVisible;
|
||
|
||
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, ISchoolHolidayRepository schoolHolidays,
|
||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
|
||
UntisHubService untisHub, WebUntisIntegrationService webUntis,
|
||
IAnnualPlanEventRepository? annualPlanEvents = null,
|
||
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
|
||
{
|
||
_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;
|
||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||
_timeEntries = timeEntries;
|
||
_substitutions = substitutions;
|
||
_annualPlanEvents = annualPlanEvents;
|
||
_schoolWeather = schoolWeather;
|
||
_untisHub = untisHub;
|
||
_webUntis = webUntis;
|
||
if (annualPlanSync is not null)
|
||
{
|
||
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
|
||
}
|
||
LoadDashboardCards();
|
||
LoadAttentionFilters();
|
||
Load();
|
||
RefreshWebUntisHealth();
|
||
}
|
||
|
||
/// <summary>Liest nur den gespeicherten Fälligkeitsstand der Untis-Hub-Jobs (kein
|
||
/// WebUntis-Zugriff, siehe <see cref="UntisHubService.GetRows"/>) - aufgerufen bei jedem
|
||
/// Dashboard-Refresh und erneut, nachdem der Nutzer den Hub geöffnet/einen Abgleich gemacht hat.</summary>
|
||
public void RefreshWebUntisHealth()
|
||
{
|
||
IsWebUntisHealthVisible = _webUntis.IsAvailable;
|
||
if (!IsWebUntisHealthVisible) return;
|
||
var rows = _untisHub.GetRows();
|
||
var due = rows.Count(r => r.DueState != UntisHubDueState.Ok);
|
||
IsWebUntisHealthWarning = due > 0;
|
||
WebUntisHealthLabel = due > 0 ? $"WebUntis ⚠ {due} fällig" : "WebUntis ✓";
|
||
}
|
||
|
||
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";
|
||
_ = LoadWeatherAsync();
|
||
|
||
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,
|
||
IsReminder = t.Kind == TaskKind.Reminder,
|
||
IsHighPriority = t.Priority == TaskPriority.High });
|
||
|
||
CurrentGroups.Clear();
|
||
foreach (var g in groups.Values)
|
||
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);
|
||
LoadExamWeekLoads(groups, today);
|
||
LoadMissingTeachingTime(today);
|
||
LoadSupportPlanReviews(today);
|
||
LoadUpcomingDates(groups, today);
|
||
LoadOpenCorrections(groups, today);
|
||
LoadUnplannedLessons(groups, today);
|
||
LoadAlerts(groups, today);
|
||
RebuildAttention();
|
||
UpdateDashboardSummary();
|
||
}
|
||
|
||
private void UpdateDashboardSummary()
|
||
{
|
||
TodayCard.IsEmpty = TodaysLessons.Count == 0;
|
||
TasksCard.IsEmpty = OpenTasks.Count == 0;
|
||
CalendarCard.IsEmpty = false;
|
||
UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
|
||
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
|
||
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
|
||
AttentionCard.IsEmpty = AttentionCount == 0;
|
||
|
||
// Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt
|
||
// ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung.
|
||
ApplyCardLayout();
|
||
|
||
OnPropertyChanged(nameof(TodayLessonCount));
|
||
OnPropertyChanged(nameof(OpenTaskCount));
|
||
OnPropertyChanged(nameof(UpcomingCount));
|
||
OnPropertyChanged(nameof(AttentionCount));
|
||
OnPropertyChanged(nameof(TodayLessonSummary));
|
||
OnPropertyChanged(nameof(OpenTaskSummary));
|
||
OnPropertyChanged(nameof(AttentionSummary));
|
||
OnPropertyChanged(nameof(UpcomingSummary));
|
||
}
|
||
|
||
private async Task LoadWeatherAsync()
|
||
{
|
||
if (_schoolWeather?.IsAvailable != true) return;
|
||
try
|
||
{
|
||
var snapshot = await _schoolWeather.GetWeatherAsync();
|
||
if (snapshot is null) return;
|
||
var current = snapshot.Forecast
|
||
.Where(x => x.ValidAt.ToUniversalTime() >= DateTime.UtcNow.AddHours(-1))
|
||
.MinBy(x => Math.Abs((x.ValidAt.ToUniversalTime() - DateTime.UtcNow).TotalMinutes));
|
||
WeatherWarnings.Clear();
|
||
foreach (var warning in snapshot.Warnings)
|
||
WeatherWarnings.Add(new DashboardWeatherWarningItem(warning));
|
||
HasWeatherWarnings = WeatherWarnings.Count > 0;
|
||
|
||
if (current is not null)
|
||
{
|
||
var temperature = current.TemperatureC is { } t ? $"{t:0.#} °C" : "Temperatur unbekannt";
|
||
WeatherSummary = $"{temperature} · {WeatherDescription(current.WeatherCode)}";
|
||
var details = new List<string>();
|
||
if (current.WindSpeedKmh is { } wind) details.Add($"Wind {wind:0.#} km/h");
|
||
if (current.WindGustKmh is { } gust) details.Add($"Böen {gust:0.#} km/h");
|
||
if (current.PrecipitationMm is > 0) details.Add($"Niederschlag {current.PrecipitationMm:0.#} mm");
|
||
details.Add($"DWD-Station {snapshot.StationName} ({snapshot.StationDistanceKm:0.#} km)");
|
||
WeatherDetails = string.Join(" · ", details);
|
||
}
|
||
WeatherStatus = snapshot.IsStale
|
||
? $"Letzter verfügbarer Stand vom {snapshot.RetrievedAt.ToLocalTime():dd.MM., HH:mm} Uhr"
|
||
: "";
|
||
IsWeatherPanelVisible = current is not null || HasWeatherWarnings;
|
||
}
|
||
catch (SchoolWeatherException ex)
|
||
{
|
||
WeatherStatus = ex.Message;
|
||
IsWeatherPanelVisible = true;
|
||
}
|
||
}
|
||
|
||
private static string WeatherDescription(int? code) => code switch
|
||
{
|
||
0 => "klar",
|
||
>= 1 and <= 3 => "bewölkt",
|
||
45 or 48 => "Nebel",
|
||
>= 51 and <= 67 => "Regen",
|
||
>= 71 and <= 77 => "Schnee",
|
||
>= 80 and <= 82 => "Regenschauer",
|
||
85 or 86 => "Schneeschauer",
|
||
>= 95 and <= 99 => "Gewitter",
|
||
_ => "Wettervorhersage",
|
||
};
|
||
|
||
// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
|
||
|
||
private void LoadAttendanceWarnings(DateOnly today)
|
||
{
|
||
_attendanceItems.Clear();
|
||
var schoolYear = _sy.CurrentSchoolYear();
|
||
var from = _sy.SchoolYearStart(schoolYear);
|
||
var to = _sy.SchoolYearEnd(schoolYear);
|
||
|
||
// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers
|
||
// einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen).
|
||
var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
|
||
|
||
var items = new List<AttendanceWarningItem>();
|
||
foreach (var student in _students.GetAll())
|
||
{
|
||
var entries = _participationEntries.GetByStudent(student.Id)
|
||
.Select(e => sessionDates.TryGetValue(e.SessionId, out var date)
|
||
? ((DateOnly?)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.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold)
|
||
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
||
}
|
||
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
||
_attendanceItems.Add(ToAttentionItem(item));
|
||
}
|
||
|
||
private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new(
|
||
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
|
||
isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId));
|
||
|
||
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
|
||
//
|
||
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
|
||
// Kollisionsprüfung, die bereits der schulische Klausurplaner übernimmt (siehe
|
||
// ExamWeekLoadService). Nur geplante, noch bevorstehende Klausuren zählen: die
|
||
// Vorausschau soll helfen, eine sich anbahnende Woche zu erkennen, bevor man eine weitere
|
||
// Klausur genau dort einträgt — bereits durchgeführte/korrigierte Klausuren werden schon
|
||
// von der Karte "Offene Korrekturen" abgedeckt.
|
||
|
||
private void LoadExamWeekLoads(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||
{
|
||
ExamWeekLoads.Clear();
|
||
var lastDay = today.AddDays(ExamLoadLookaheadDays);
|
||
var upcoming = groups.Keys
|
||
.SelectMany(gid => _exams.GetByGroup(gid))
|
||
.Where(e => e.Status == ExamStatus.Planned && e.Date >= today && e.Date <= lastDay);
|
||
|
||
foreach (var week in ExamWeekLoadService.FindOverloadedWeeks(upcoming))
|
||
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
|
||
}
|
||
|
||
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
|
||
//
|
||
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
|
||
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
|
||
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
|
||
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
|
||
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
|
||
|
||
private void LoadMissingTeachingTime(DateOnly today)
|
||
{
|
||
_missingTimeItems.Clear();
|
||
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
|
||
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
|
||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||
.Select(h => h.Date).ToHashSet();
|
||
var schoolHolidays = _schoolHolidays.GetAll();
|
||
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
|
||
|
||
var items = new List<MissingTeachingTimeItem>();
|
||
for (var date = firstDay; date <= today; date = date.AddDays(1))
|
||
{
|
||
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||
|
||
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||
if (daySlots.Count == 0) continue;
|
||
|
||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||
.Select(s => s.PeriodNumber).ToHashSet();
|
||
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
|
||
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
|
||
.Where(t => t is not null).Select(t => t!.Value).ToList();
|
||
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
|
||
|
||
var lastPeriodEnd = periodTimes.Max(t => t.End);
|
||
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
|
||
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
|
||
|
||
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
|
||
|
||
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
|
||
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
|
||
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
|
||
}
|
||
foreach (var item in items.OrderBy(i => i.Date))
|
||
_missingTimeItems.Add(new AttentionItem(AttentionKind.MissingTeachingTime, item.DateDisplay,
|
||
actions: [new AttentionAction("Erfassen", AddMissingTeachingTimeCommand, item)]));
|
||
}
|
||
|
||
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
|
||
|
||
private void LoadSupportPlanReviews(DateOnly today)
|
||
{
|
||
_supportItems.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;
|
||
var reviewDate = d.SupportData!.ReviewDate!.Value;
|
||
var studentId = d.StudentId;
|
||
_supportItems.Add(new AttentionItem(AttentionKind.SupportPlan, student.FullName, subtitle: d.Title,
|
||
dateDisplay: reviewDate.ToString("dd.MM.yyyy"), isOverdue: reviewDate < today,
|
||
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
|
||
}
|
||
}
|
||
|
||
// ── 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)
|
||
{
|
||
_correctionItems.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, evaluated) = ExamCorrectionCounter.Count(exam,
|
||
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
|
||
var percent = expected == 0 ? 0 : (int)Math.Round(evaluated * 100.0 / expected);
|
||
var groupId = group.Id;
|
||
_correctionItems.Add(new AttentionItem(AttentionKind.Correction, exam.Title, subtitle: group.Name,
|
||
dateDisplay: exam.Date.ToString("dd.MM.yyyy"),
|
||
isOverdue: exam.Date < today.AddDays(-7) && evaluated < expected,
|
||
progressPercent: percent, progressLabel: $"{evaluated} von {expected} Arbeiten bewertet", hasProgress: true,
|
||
navigate: () => OnNavigateToExam?.Invoke(groupId)));
|
||
}
|
||
}
|
||
|
||
// ── Ungeplante Stunden (Nutzer-Feedback: Erinnerung an Stunden ohne Thema) ────────────────
|
||
//
|
||
// Nur heute + UnplannedLessonsLookaheadDays (bewusst knapp, siehe Konstante oben) — für jede
|
||
// Gruppe mit RequiresLessonPlanning und einem Stundenplan-Slot an diesem Wochentag wird
|
||
// geprüft, ob dafür bereits eine Lesson mit Thema existiert. Gruppen ohne eigenen Verlaufsplan
|
||
// (Klassenrat, Willkommenskreis) lassen sich in den Stammdaten der Gruppe ausnehmen. Ein Slot,
|
||
// für den an diesem Datum ein SubstitutionKind.Cancelled-Eintrag (4.3, "Stundenausfall")
|
||
// vorliegt, fällt ganz weg — gleiche Prüfung (Datum + Stundennummer, ohne Gruppenbezug) wie in
|
||
// TimetableViewModel.BuildToday.
|
||
|
||
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||
{
|
||
_unplannedItems.Clear();
|
||
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
|
||
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
|
||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||
.Select(h => h.Date).ToHashSet();
|
||
var schoolHolidays = _schoolHolidays.GetAll();
|
||
|
||
var items = new List<(DateOnly Date, int PeriodNumber, LearningGroup Group)>();
|
||
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
|
||
{
|
||
var slots = _timetableSlots.GetByGroup(group.Id);
|
||
if (slots.Count == 0) continue;
|
||
|
||
for (var date = today; date <= lastDay; date = date.AddDays(1))
|
||
{
|
||
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||
var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||
if (daySlots.Count == 0) continue;
|
||
var periodsWithSlot = daySlots.Select(s => s.PeriodNumber).ToHashSet();
|
||
var dayLessons = _lessons.GetByGroupAndDate(group.Id, date).ToList();
|
||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||
.Select(s => s.PeriodNumber).ToHashSet();
|
||
|
||
foreach (var slot in daySlots)
|
||
{
|
||
if (cancelledPeriods.Contains(slot.PeriodNumber)) continue;
|
||
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == slot.PeriodNumber);
|
||
if (lesson is not null)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(lesson.Topic)) continue;
|
||
}
|
||
else if (IsCoveredByEarlierDoppelstunde(dayLessons, periodsWithSlot, slot.PeriodNumber))
|
||
{
|
||
continue;
|
||
}
|
||
items.Add((date, slot.PeriodNumber, group));
|
||
}
|
||
}
|
||
}
|
||
foreach (var (date, periodNumber, group) in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber)
|
||
.ThenBy(i => i.Group.Name))
|
||
{
|
||
var dateDisplay = date == today ? "Heute" : date == today.AddDays(1) ? "Morgen" : date.ToString("dd.MM.");
|
||
var groupId = group.Id;
|
||
_unplannedItems.Add(new AttentionItem(AttentionKind.Unplanned,
|
||
$"{group.Name} · {periodNumber}. Stunde", dateDisplay: dateDisplay,
|
||
navigate: () => OnNavigateToUnplannedLesson?.Invoke(groupId)));
|
||
}
|
||
}
|
||
|
||
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
|
||
/// bei einer früheren Stundennummer beginnt — gleiches Prinzip wie
|
||
/// PlanningViewModels.LessonDialogViewModel.RecomputeTimeBudget: ausgehend von der Vorperiode
|
||
/// wird rückwärts so lange die jeweils vorherige Periode geprüft, wie der Stundenplan dafür
|
||
/// noch einen Slot hat. Trifft man dabei auf eine Lesson, entscheidet deren Thema (vorhanden =
|
||
/// Doppelstunde bereits geplant); trifft man auf eine Periode ohne Lesson, wird weiter
|
||
/// zurückgegangen; bricht die Slot-Kette ab, ohne eine Lesson gefunden zu haben, ist die Periode
|
||
/// nicht abgedeckt.</summary>
|
||
private static bool IsCoveredByEarlierDoppelstunde(
|
||
List<Lesson> dayLessons, HashSet<int> periodsWithSlot, int periodNumber)
|
||
{
|
||
for (var period = periodNumber - 1; periodsWithSlot.Contains(period); period--)
|
||
{
|
||
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == period);
|
||
if (lesson is not null) return !string.IsNullOrWhiteSpace(lesson.Topic);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in
|
||
/// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein
|
||
/// Service-Aufwand für eine dritte Fundstelle.</summary>
|
||
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
|
||
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
|
||
|
||
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
|
||
|
||
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||
{
|
||
_alertItems.Clear();
|
||
// Fehlzeiten-Auffälligkeiten erscheinen in der zusammengefassten Handlungsbedarf-Karte
|
||
// bereits als eigene Gruppe "Fehlzeiten-Warnung" (LoadAttendanceWarnings) — eine weitere
|
||
// Kopie hier wäre jetzt eine sichtbare Dopplung derselben Schüler/Zahl, die vor dem Merge
|
||
// durch zwei getrennte Kacheln (Auffälligkeiten vs. Fehlzeiten-Warnung) nicht auffiel.
|
||
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 studentId = student.Id;
|
||
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)
|
||
_alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
|
||
subtitle: $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
|
||
trailingText: "Notenabfall", severity: AlertSeverity.Medium,
|
||
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
|
||
}
|
||
|
||
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))
|
||
_alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
|
||
subtitle: $"{group.Name}: aktueller Stand {reportValue}",
|
||
trailingText: "Versetzungsgefährdung", severity: AlertSeverity.High,
|
||
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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",
|
||
"upcoming" => "Anstehende Termine", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
|
||
"attention" => "Handlungsbedarf", _ => key,
|
||
};
|
||
|
||
// ── Handlungsbedarf: Filter-Chips ─────────────────────────────────────────
|
||
//
|
||
// Ersetzt die frühere Sichtbarkeit je Einzelkachel (sieben Schalter im "Bereiche anpassen"-
|
||
// Panel) durch Filter-Chips innerhalb der zusammengefassten Karte — dichter, und der
|
||
// naheliegende Ort, weil alle sieben jetzt eine Karte sind. Bewusst nur für die Dauer der
|
||
// Sitzung (keine Persistenz über DashboardSettingsService): das JSON-Format dort ist eine
|
||
// flache Liste von Kachel-Einstellungen, eine zweite Objektform nur für diese sieben Filter
|
||
// hätte das Dateiformat aufgespalten, ohne dass "welche Handlungsbedarf-Art blende ich
|
||
// dauerhaft aus" bisher als Bedürfnis geäußert wurde.
|
||
|
||
private void LoadAttentionFilters()
|
||
{
|
||
AttentionFilters.Clear();
|
||
foreach (var (kind, header) in AttentionGroupOrder)
|
||
{
|
||
var option = new AttentionFilterOption(kind, header) { OnChanged = RebuildAttention };
|
||
AttentionFilters.Add(option);
|
||
}
|
||
}
|
||
|
||
private bool IsAttentionFilterActive(AttentionKind kind) =>
|
||
AttentionFilters.FirstOrDefault(f => f.Kind == kind)?.IsActive ?? true;
|
||
|
||
/// <summary>Setzt Attention aus den bereits geladenen _xyzItems-Feldern neu zusammen — reine
|
||
/// Umsortierung/Filterung im Speicher, kein Repo-Zugriff. Wird nach jedem Load() sowie nach
|
||
/// jeder punktuellen Änderung (Entschuldigung aufgelöst, Zeit nacherfasst, Filter-Chip
|
||
/// umgeschaltet) aufgerufen.</summary>
|
||
private void RebuildAttention()
|
||
{
|
||
var byKind = new Dictionary<AttentionKind, IReadOnlyList<AttentionItem>>
|
||
{
|
||
[AttentionKind.MissingTeachingTime] = _missingTimeItems,
|
||
[AttentionKind.Excuse] = _excuses.Select(ToAttentionItem).ToList(),
|
||
[AttentionKind.Correction] = _correctionItems,
|
||
[AttentionKind.Unplanned] = _unplannedItems,
|
||
[AttentionKind.Alert] = _alertItems,
|
||
[AttentionKind.Attendance] = _attendanceItems,
|
||
[AttentionKind.SupportPlan] = _supportItems,
|
||
};
|
||
|
||
Attention.Clear();
|
||
foreach (var (kind, header) in AttentionGroupOrder)
|
||
{
|
||
if (!IsAttentionFilterActive(kind)) continue;
|
||
var items = byKind[kind];
|
||
if (items.Count == 0) continue;
|
||
Attention.Add(new AttentionGroup(kind, header, items));
|
||
}
|
||
}
|
||
|
||
private static AttentionItem ToAttentionItem(OpenExcuseItem e) => new(
|
||
AttentionKind.Excuse, e.StudentName, subtitle: $"{e.GroupName} · {e.DateDisplay}",
|
||
actions: [new AttentionAction("Entschuldigt", e.MarkExcusedCommand),
|
||
new AttentionAction("Unentschuldigt", e.MarkUnexcusedCommand)]);
|
||
|
||
// Zaehlt bewusst EffectiveIsVisible, nicht IsVisible: eine eingeschaltete, aber gerade leere
|
||
// HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen,
|
||
// sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid.
|
||
private void ApplyCardLayout()
|
||
{
|
||
var visibleIndex = 0;
|
||
foreach (var card in DashboardCards)
|
||
{
|
||
var index = card.EffectiveIsVisible ? 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();
|
||
}
|
||
|
||
/// <summary>Ersetzt die früheren fünf eigenen Navigations-Commands (OpenStudentAttendance,
|
||
/// OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) — jedes AttentionItem
|
||
/// trägt sein Sprungziel bereits als Closure in Navigate.</summary>
|
||
[RelayCommand] private void OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke();
|
||
|
||
[RelayCommand]
|
||
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
|
||
{
|
||
if (item is null || OnAddMissingTeachingTime is null) return;
|
||
await OnAddMissingTeachingTime(item);
|
||
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
|
||
RebuildAttention();
|
||
UpdateDashboardSummary();
|
||
}
|
||
|
||
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
|
||
{
|
||
_excuses.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);
|
||
}
|
||
}
|
||
}
|
||
_excuses.AddRange(items.OrderBy(i => i.Date));
|
||
}
|
||
|
||
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);
|
||
_excuses.Remove(item);
|
||
RebuildAttention();
|
||
UpdateDashboardSummary();
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
// Sitzungen, die über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstanden sind
|
||
// (LessonId gesetzt), bekommen bewusst keinen eigenen Kalendereintrag — die Stunde
|
||
// selbst ist an diesem Tag/dieser Gruppe schon als Lesson-Termin gelistet, ein
|
||
// zweiter Eintrag für dieselbe Unterrichtsstunde wäre eine Dopplung.
|
||
foreach (var session in _participationSessions.GetByGroup(g.Id)
|
||
.Where(s => s.LessonId is null && s.Date >= gridStart && s.Date <= gridEnd))
|
||
{
|
||
var agg = Agg(session.Date);
|
||
agg.HasSession = true;
|
||
if (g.IsOwnClass) agg.IsOwnClassDay = true;
|
||
agg.Details.Add(new CalendarEventItem(CalendarEventKind.ParticipationSession, session.Date,
|
||
g.Name, session.Comment ?? "", g.Id));
|
||
}
|
||
}
|
||
|
||
// Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an
|
||
// keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem
|
||
// betroffenen Kalendertag.
|
||
if (_annualPlanEvents is not null)
|
||
{
|
||
foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd)
|
||
.Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate;
|
||
var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate;
|
||
for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1))
|
||
{
|
||
var agg = Agg(date);
|
||
agg.HasAnnualPlanEvent = true;
|
||
agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date,
|
||
annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null,
|
||
annualEvent.Description));
|
||
}
|
||
}
|
||
}
|
||
|
||
// "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
|
||
// existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den
|
||
// aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht.
|
||
// Dieselbe Projektion wie bei den ungeplanten Stunden (LoadUnplannedLessons), nur über den
|
||
// gesamten Kalenderraster statt nur die nächsten Tage.
|
||
var publicHolidayDatesForGrid = Enumerable.Range(gridStart.Year, gridEnd.Year - gridStart.Year + 1)
|
||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||
.Select(h => h.Date).ToHashSet();
|
||
var schoolHolidaysForGrid = _schoolHolidays.GetAll();
|
||
foreach (var g in groups.Where(g => g.IsOwnClass))
|
||
{
|
||
var slots = _timetableSlots.GetByGroup(g.Id);
|
||
if (slots.Count == 0) continue;
|
||
|
||
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
||
{
|
||
if (IsFreeDay(date, schoolHolidaysForGrid, publicHolidayDatesForGrid)) continue;
|
||
var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||
if (daySlots.Count == 0) continue;
|
||
|
||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||
.Select(s => s.PeriodNumber).ToHashSet();
|
||
if (daySlots.All(s => cancelledPeriods.Contains(s.PeriodNumber))) continue;
|
||
|
||
Agg(date).IsOwnClassDay = true;
|
||
}
|
||
}
|
||
|
||
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?.HasSession ?? false,
|
||
agg?.HasAnnualPlanEvent ?? 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;
|
||
SelectedCalendarDate = day.Date;
|
||
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
|
||
SelectedDayEvents.Clear();
|
||
foreach (var item in day.Events) SelectedDayEvents.Add(item);
|
||
SortCurrentGroups(day.Date);
|
||
}
|
||
|
||
private void SortCurrentGroups(DateOnly date)
|
||
{
|
||
var schoolHolidays = _schoolHolidays.GetAll();
|
||
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
|
||
.Select(h => h.Date).ToHashSet();
|
||
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
|
||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||
.Select(s => s.PeriodNumber).ToHashSet();
|
||
|
||
foreach (var chip in CurrentGroups)
|
||
{
|
||
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
|
||
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
|
||
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
|
||
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
|
||
}
|
||
|
||
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
|
||
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||
CurrentGroups.Clear();
|
||
foreach (var chip in sorted) CurrentGroups.Add(chip);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void OpenCalendarEvent(CalendarEventItem? item)
|
||
{
|
||
if (item is null) return;
|
||
if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information.
|
||
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId);
|
||
else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit"
|
||
}
|
||
|
||
[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 Refresh() => Load();
|
||
|
||
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
|
||
[RelayCommand] private Task AddReminder() => AddTaskInternal(startAsReminder: true);
|
||
|
||
private async Task AddTaskInternal(bool startAsReminder)
|
||
{
|
||
if (OnAddTask is null) return;
|
||
var result = await OnAddTask(startAsReminder);
|
||
if (result is null) return;
|
||
_tasks.Save(result);
|
||
Load();
|
||
}
|
||
|
||
private class DayAgg
|
||
{
|
||
public bool HasLesson;
|
||
public bool HasExam;
|
||
public bool HasSession;
|
||
public bool HasAnnualPlanEvent;
|
||
public bool IsOwnClassDay;
|
||
public List<CalendarEventItem> Details { get; } = [];
|
||
}
|
||
|
||
private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry)
|
||
{
|
||
string time;
|
||
if (entry.IsAllDay)
|
||
{
|
||
time = entry.StartDate == entry.EndDate
|
||
? "Ganztägig"
|
||
: $"{entry.StartDate:dd.MM.}–{entry.EndDate:dd.MM.yyyy} · ganztägig";
|
||
}
|
||
else if (entry.StartDate == entry.EndDate)
|
||
{
|
||
time = entry.EndTime is { } end
|
||
? $"{entry.StartTime:HH\\:mm}–{end:HH\\:mm}"
|
||
: $"{entry.StartTime:HH\\:mm}";
|
||
}
|
||
else
|
||
{
|
||
time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}–" +
|
||
$"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}";
|
||
}
|
||
|
||
return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location }
|
||
.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||
}
|
||
}
|
||
|
||
public sealed class DashboardWeatherWarningItem
|
||
{
|
||
public string Headline { get; }
|
||
public string Description { get; }
|
||
public string Instruction { get; }
|
||
public string PeriodDisplay { get; }
|
||
public string SeverityColor { get; }
|
||
|
||
public DashboardWeatherWarningItem(WeatherWarning warning)
|
||
{
|
||
Headline = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline;
|
||
Description = warning.Description;
|
||
Instruction = warning.Instruction;
|
||
PeriodDisplay = (warning.Onset, warning.Expires) switch
|
||
{
|
||
({ } onset, { } expires) => $"{onset.ToLocalTime():dd.MM., HH:mm}–{expires.ToLocalTime():HH:mm} Uhr",
|
||
({ } onset, null) => $"ab {onset.ToLocalTime():dd.MM., HH:mm} Uhr",
|
||
_ => "",
|
||
};
|
||
SeverityColor = warning.Severity switch
|
||
{
|
||
"Extreme" => "#7E0023", "Severe" => "#D32F2F", "Moderate" => "#F59E0B", _ => "#FDD835",
|
||
};
|
||
}
|
||
}
|
||
|
||
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 bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
|
||
public class GroupChip
|
||
{
|
||
public Guid GroupId { get; set; }
|
||
public string Name { get; set; } = "";
|
||
public string Subject { get; set; } = "";
|
||
public bool IsOnSelectedDay { 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;
|
||
}
|
||
|
||
public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount)
|
||
{
|
||
public int ExamCount { get; } = examCount;
|
||
public string Label => $"KW {isoWeek} ({weekStart:dd.MM.}–{weekEnd:dd.MM.})";
|
||
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
|
||
}
|
||
|
||
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
|
||
{
|
||
private static readonly CultureInfo De = new("de-DE");
|
||
|
||
public DateOnly Date { get; } = date;
|
||
public TimeOnly WindowStart { get; } = windowStart;
|
||
public TimeOnly WindowEnd { get; } = windowEnd;
|
||
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
|
||
}
|
||
|
||
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 HasSession { get; }
|
||
public bool HasAnnualPlanEvent { 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 hasSession, bool hasAnnualPlanEvent,
|
||
bool isOwnClassDay, List<CalendarEventItem> details)
|
||
{
|
||
Date = date;
|
||
DayNumber = date.Day;
|
||
IsCurrentMonth = isCurrentMonth;
|
||
IsToday = isToday;
|
||
HasLesson = hasLesson;
|
||
HasExam = hasExam;
|
||
HasSession = hasSession;
|
||
HasAnnualPlanEvent = hasAnnualPlanEvent;
|
||
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, ParticipationSession, AnnualPlan }
|
||
|
||
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
|
||
string subtitle, Guid? groupId, string description = "")
|
||
{
|
||
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 Description { get; } = description;
|
||
public string KindLabel => Kind switch
|
||
{
|
||
CalendarEventKind.Exam => "Klausur",
|
||
CalendarEventKind.ParticipationSession => "Sitzung",
|
||
CalendarEventKind.AnnualPlan => "Jahresplan",
|
||
_ => "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 enum AlertSeverity { Medium, High }
|
||
|
||
public partial class DashboardCardOption : ObservableObject
|
||
{
|
||
[ObservableProperty] private bool _isVisible;
|
||
[ObservableProperty] private bool _isEmpty;
|
||
[ObservableProperty] private int _row;
|
||
[ObservableProperty] private int _column;
|
||
public string Key { get; }
|
||
public string Title { get; }
|
||
public bool HideWhenEmpty { get; }
|
||
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
|
||
public Action? OnVisibilityChanged { get; set; }
|
||
|
||
/// <summary>Rinnstein zur jeweils anderen Rasterspalte. Muss aus der berechneten
|
||
/// <see cref="Column"/> kommen und darf nicht im XAML fest an der Kachel haengen: welche Kachel
|
||
/// links und welche rechts landet, entscheidet sich erst zur Laufzeit aus Reihenfolge und
|
||
/// Sichtbarkeit, ein fester Margin sitzt dann bei jeder Umschaltung auf der falschen Seite.</summary>
|
||
public Avalonia.Thickness Margin => Column == 0
|
||
? new Avalonia.Thickness(0, 0, 8, 8)
|
||
: new Avalonia.Thickness(8, 0, 0, 8);
|
||
|
||
public DashboardCardOption(string key, string title, bool isVisible)
|
||
{
|
||
Key = key;
|
||
Title = title;
|
||
HideWhenEmpty = key is "upcoming" or "attention";
|
||
_isVisible = isVisible;
|
||
}
|
||
|
||
partial void OnIsVisibleChanged(bool value)
|
||
{
|
||
OnPropertyChanged(nameof(EffectiveIsVisible));
|
||
OnVisibilityChanged?.Invoke();
|
||
}
|
||
|
||
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
|
||
|
||
partial void OnColumnChanged(int value) => OnPropertyChanged(nameof(Margin));
|
||
}
|