CI / build-and-test (push) Canceled after 0s
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>
159 lines
7.5 KiB
C#
159 lines
7.5 KiB
C#
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,
|
||
};
|
||
}
|