Files
LehrerApp/LehrerApp.Desktop/ViewModels/StudentWarningDetailViewModel.cs
adminandClaude Opus 5.5 aa326cf8b6
CI / build-and-test (push) Canceled after 0s
Add warning detail popup and quick blocks for Vorgänge
Dashboard warnings (attendance, homework, lateness) get a "Details" flyout
with key figures, a list of dates and the compact month calendar from the
class teacher overview, now extracted as a shared control. The calendar
now aligns days under their weekday (Mo-So header, blank leading cells).

Creating Vorgänge and documentation is supported by quick blocks with the
current figures, dated standard phrases (e.g. "Klassenleitung informiert")
and documentation templates (status snapshot, conversation, informing the
class/subject teachers, parent call, parent letter). The class teacher
Vorgänge tab uses WebUntis figures (absences across all subjects, class
register) for the same blocks and templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:16:13 +02:00

159 lines
7.5 KiB
C#
Raw Permalink 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 System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels;
public sealed record WarningDetailFigure(string Label, string Value);
/// <summary>
/// Inhalt des "Details"-Popups einer klassenübergreifenden Dashboard-Warnung (Nutzer-Feedback: im
/// Eintrag mehr erfahren, ohne erst ins Schülerdetail zu springen). Recycelt das Monats-Widget der
/// Klassenlehrer-Übersicht ("Monat auf einen Blick", <see cref="ClassTeacherCompactCalendarDay"/> +
/// Views/Shared/CompactMonthCalendar) — dort aus WebUntis-Daten der eigenen Klasse, hier aus den
/// lokal erfassten Mitarbeits-Einträgen aller Lerngruppen, mit denselben Kürzeln/Farben.
/// Wird erst beim Öffnen des Popups erzeugt (AttentionItem.Detail ist lazy), damit das Dashboard
/// nicht für jede Warnzeile vorab alle Belege zusammensucht.
/// </summary>
public partial class StudentWarningDetailViewModel : ObservableObject
{
private static readonly CultureInfo De = new("de-DE");
private const int MaxListedEvents = 10;
private readonly IReadOnlyList<StudentEvidenceEvent> _calendarEvents;
private readonly DateOnly _firstMonth;
private readonly DateOnly _lastMonth;
private readonly DateOnly _today;
public string Header { get; }
public string Summary { get; }
public IReadOnlyList<WarningDetailFigure> Figures { get; }
public string ListHeader { get; }
public IReadOnlyList<StudentEvidenceEvent> Events { get; }
public string MoreEventsHint { get; }
public bool HasMoreEvents => !string.IsNullOrEmpty(MoreEventsHint);
public bool HasEvents => Events.Count > 0;
public string Legend { get; }
public ObservableCollection<ClassTeacherCompactCalendarDay> CalendarDays { get; } = [];
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MonthLabel))]
[NotifyCanExecuteChangedFor(nameof(PreviousMonthCommand), nameof(NextMonthCommand))]
private DateOnly _month;
public string MonthLabel => Month.ToString("MMMM yyyy", De);
public StudentWarningDetailViewModel(AttentionKind kind, StudentWarningEvidence e)
{
var b = e.Balance;
Header = e.StudentName;
switch (kind)
{
case AttentionKind.MissingHomework:
Summary = $"{e.MissingHomework.Count} offene Hausaufgaben · Schuljahr {e.SchoolYear}";
Figures =
[
new("Nicht gemacht", e.MissingHomework.Count(x => x.Label.StartsWith("Nicht", StringComparison.Ordinal)).ToString()),
new("Teilweise", e.MissingHomework.Count(x => x.Label.StartsWith("Teilweise", StringComparison.Ordinal)).ToString()),
];
ListHeader = "Betroffene Termine";
_calendarEvents = e.MissingHomework;
Events = e.MissingHomework;
Legend = "H Hausaufgabe offen/unvollständig";
break;
case AttentionKind.Lateness:
Summary = $"{e.Lateness.Count}× verspätet · Schuljahr {e.SchoolYear}";
Figures =
[
new("Verspätet", (e.Lateness.Count - e.SignificantlyLateCount).ToString()),
new("Erheblich", e.SignificantlyLateCount.ToString()),
new("Fehlquote", $"{b.AbsenceRatePercent.ToString("0.#", De)} %"),
];
ListHeader = "Verspätungen";
_calendarEvents = [.. e.Absences, .. e.Lateness];
Events = e.Lateness;
Legend = "V verspätet · U unentsch. · A Entsch. offen · E entsch.";
break;
default:
Summary = $"Fehlquote {b.AbsenceRatePercent.ToString("0.#", De)} % · {b.TotalChecked} Stunden erfasst · Schuljahr {e.SchoolYear}";
Figures =
[
new("Entschuldigt", b.Excused.ToString()),
new("Unentschuldigt", b.Unexcused.ToString()),
new("Entsch. offen", b.ExcusePending.ToString()),
new("Verspätet", e.Lateness.Count.ToString()),
];
ListHeader = "Fehltermine";
_calendarEvents = [.. e.Absences, .. e.Lateness];
Events = e.Absences;
Legend = "U unentsch. · A Entsch. offen · E entsch. · V verspätet";
break;
}
if (Events.Count > MaxListedEvents)
{
MoreEventsHint = $"… und {Events.Count - MaxListedEvents} weitere";
Events = Events.Take(MaxListedEvents).ToList();
}
else MoreEventsHint = "";
_firstMonth = FirstOfMonth(e.From);
_lastMonth = FirstOfMonth(e.AsOf < e.To ? e.AsOf : e.To);
if (_lastMonth < _firstMonth) _lastMonth = _firstMonth;
// Startet beim Monat des jüngsten Belegs — der aktuelle Monat kann zu Monatsbeginn noch
// leer sein, obwohl die Warnung gerade wegen der Vorwochen anschlägt.
var latest = Events.Count > 0 ? FirstOfMonth(Events[0].Date) : _lastMonth;
_today = e.AsOf;
Month = latest < _firstMonth ? _firstMonth : latest > _lastMonth ? _lastMonth : latest;
RebuildCalendar();
}
private static DateOnly FirstOfMonth(DateOnly d) => new(d.Year, d.Month, 1);
partial void OnMonthChanged(DateOnly value) => RebuildCalendar();
private bool CanGoPrevious() => Month > _firstMonth;
private bool CanGoNext() => Month < _lastMonth;
[RelayCommand(CanExecute = nameof(CanGoPrevious))] private void PreviousMonth() => Month = Month.AddMonths(-1);
[RelayCommand(CanExecute = nameof(CanGoNext))] private void NextMonth() => Month = Month.AddMonths(1);
private void RebuildCalendar()
{
CalendarDays.Clear();
foreach (var day in BuildMonth(Month, _calendarEvents, _today)) CalendarDays.Add(day);
}
/// Gleiche Verdichtung wie ClassTeacherOverviewViewModel.BuildCompactMonthDays: eine Zelle je
/// Kalendertag, das stärkste Kürzel ersetzt die Tageszahl, alle Details stehen im Tooltip.
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildMonth(DateOnly month,
IReadOnlyList<StudentEvidenceEvent> events, DateOnly today)
{
var result = new List<ClassTeacherCompactCalendarDay>();
var byDate = events.Where(e => e.Date.Year == month.Year && e.Date.Month == month.Month)
.GroupBy(e => e.Date).ToDictionary(g => g.Key, g => g.ToList());
for (var n = 1; n <= DateTime.DaysInMonth(month.Year, month.Month); n++)
{
var date = new DateOnly(month.Year, month.Month, n);
if (!byDate.TryGetValue(date, out var dayEvents))
{
result.Add(new ClassTeacherCompactCalendarDay(date, "", "#00000000",
$"{date:dd.MM.yyyy}: unauffällig", date == today));
continue;
}
var strongest = dayEvents.OrderBy(e => Priority(e.Code)).First();
var tooltip = $"{date:dd.MM.yyyy}\n" + string.Join("\n", dayEvents.Select(e => $"{e.GroupName}: {e.Label}"));
result.Add(new ClassTeacherCompactCalendarDay(date, strongest.Code.TrimEnd('!'), strongest.ColorHex,
tooltip, date == today));
}
return result;
}
private static int Priority(string code) => code switch
{
"U" => 0, "A" => 1, "E" => 2, "V!" => 3, "V" => 4, _ => 5,
};
}