Klassenlehrer-Tab: vier Feature-Ideen umgesetzt (ohne Elterngesprächs-PDF)
Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend. ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias $parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) — stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als PDF bewusst zurückgestellt, Konzept noch nicht geschärft. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,33 @@ public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
|
||||
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
|
||||
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
|
||||
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
|
||||
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
|
||||
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
|
||||
{
|
||||
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
|
||||
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
|
||||
|
||||
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
|
||||
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
|
||||
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
|
||||
.GroupBy(e => e.CategoryName!)
|
||||
.Select(g =>
|
||||
{
|
||||
var topStudents = g.GroupBy(e => e.StudentDisplayName)
|
||||
.Select(sg => (Name: sg.Key, Count: sg.Count()))
|
||||
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
|
||||
.Take(topStudentsPerCategory)
|
||||
.Select(sg => $"{sg.Count}× {sg.Name}");
|
||||
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
|
||||
})
|
||||
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
|
||||
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
|
||||
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
|
||||
@@ -107,6 +134,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
|
||||
|
||||
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
|
||||
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
|
||||
@@ -118,6 +146,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
|
||||
public bool HasEntries => Entries.Count > 0;
|
||||
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
|
||||
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
|
||||
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
|
||||
? "Alle Schüler*innen" : StudentFilter;
|
||||
|
||||
@@ -132,6 +161,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
StudentFilter = "";
|
||||
Entries.Clear();
|
||||
AbsenceEntries.Clear();
|
||||
CategoryAggregates.Clear();
|
||||
Status = "Zeitraum wählen und laden.";
|
||||
NotifyListState();
|
||||
}
|
||||
@@ -170,7 +200,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
|
||||
|
||||
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); NotifyListState();
|
||||
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
|
||||
try
|
||||
{
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
|
||||
@@ -187,6 +217,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
|
||||
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
|
||||
}
|
||||
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
|
||||
|
||||
var absences = absencesTask.Result.Where(MatchesStudentFilter);
|
||||
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
|
||||
@@ -216,5 +247,6 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
||||
{
|
||||
OnPropertyChanged(nameof(HasEntries));
|
||||
OnPropertyChanged(nameof(HasAbsenceEntries));
|
||||
OnPropertyChanged(nameof(HasCategoryAggregates));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
@@ -163,11 +164,40 @@ public sealed record ClassTeacherPatternNotice(string StudentName, string Messag
|
||||
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
|
||||
}
|
||||
|
||||
/// <summary>Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird —
|
||||
/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend,
|
||||
/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin
|
||||
/// für die Jahresfehlquote geladenen Daten (<see cref="ClassTeacherOverviewViewModel.Load"/>),
|
||||
/// kein zusätzlicher Abruf.</summary>
|
||||
public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen)
|
||||
{
|
||||
/// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen"
|
||||
/// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische
|
||||
/// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche
|
||||
/// Regelung kann je nach Bundesland/Schulordnung abweichen.
|
||||
public const int DeadlineDays = 3;
|
||||
public string DateLabel => Date.ToString("dd.MM.");
|
||||
public bool IsOverdue => DaysOpen > DeadlineDays;
|
||||
public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt";
|
||||
|
||||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||||
absenceDays.Where(a => a.IsUnexcused)
|
||||
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||||
today.DayNumber - a.Date.DayNumber))
|
||||
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
private readonly WebUntisSettingsService _settings;
|
||||
private readonly UntisReportCacheService _cache;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly IWorkTaskRepository _workTasks;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
|
||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||
@@ -175,6 +205,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||||
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||||
|
||||
[ObservableProperty] private string? _homeroomClassName;
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
@@ -193,12 +224,15 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
[ObservableProperty] private int _excusedAbsenceCount;
|
||||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||||
[ObservableProperty] private int _openExcuseOverflowCount;
|
||||
|
||||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||||
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
|
||||
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
|
||||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||||
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
|
||||
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
|
||||
public bool AllFilterSelected => SelectedRosterFilter == 2;
|
||||
@@ -235,15 +269,22 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, ClassTeacherDetailsViewModel detailsTab)
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
||||
{
|
||||
_settings = settings;
|
||||
_cache = cache;
|
||||
_schoolYear = schoolYear;
|
||||
_workTasks = workTasks;
|
||||
_students = students;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
DetailsTab = detailsTab;
|
||||
}
|
||||
|
||||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||
partial void OnSelectedRosterFilterChanged(int value)
|
||||
{
|
||||
@@ -259,6 +300,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
HomeroomClassName = _settings.HomeroomClassName;
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||||
if (!HomeroomClassConfigured)
|
||||
{
|
||||
Status = "Noch keine Klasse ausgewählt.";
|
||||
@@ -302,6 +344,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(weekAbsenceDays);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||||
ApplyRosterFilter();
|
||||
@@ -436,6 +481,174 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||||
/// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren
|
||||
/// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares
|
||||
/// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7
|
||||
/// Tagen. Wer schon eine Notiz aus <see cref="BuildPatternNotices"/> hat, wird hier
|
||||
/// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt.
|
||||
private void BuildWeekdayPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
{
|
||||
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||||
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||||
var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet();
|
||||
foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted))
|
||||
PatternNotices.Add(notice);
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie
|
||||
/// <see cref="ClassTeacherRosterRow.Build"/>). <paramref name="minSampleSize"/> verhindert,
|
||||
/// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten;
|
||||
/// <paramref name="clusterThreshold"/> verlangt eine deutliche Häufung, nicht nur eine leichte
|
||||
/// Mehrheit.
|
||||
public static IReadOnlyList<ClassTeacherPatternNotice> DetectWeekdayPatterns(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays,
|
||||
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||||
ISet<string> excludeDisplayNames,
|
||||
int minSampleSize = 3, double clusterThreshold = 0.6)
|
||||
{
|
||||
var notices = new List<ClassTeacherPatternNotice>();
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
{
|
||||
var rows = group.ToList();
|
||||
if (rows.Count < minSampleSize) continue;
|
||||
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||||
if (excludeDisplayNames.Contains(displayName)) continue;
|
||||
|
||||
var top = rows.GroupBy(r => r.Date.DayOfWeek)
|
||||
.Select(g => (Day: g.Key, Count: g.Count()))
|
||||
.OrderByDescending(g => g.Count).First();
|
||||
if ((double)top.Count / rows.Count < clusterThreshold) continue;
|
||||
|
||||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}",
|
||||
ClassTeacherStatusKind.Info));
|
||||
}
|
||||
return notices;
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das
|
||||
/// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote
|
||||
/// (<see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/>) mit dem Mitarbeit-Trend aus
|
||||
/// dem Dokumentations-/Mitarbeitsmodul (<see cref="IParticipationRepository"/>, gruppen-
|
||||
/// übergreifend über <c>GetByStudent</c>, gleiches Muster wie die Fehlzeitenbilanz in
|
||||
/// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote
|
||||
/// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche
|
||||
/// simple Heuristik wie <c>ParticipationGradeRow.ComputeTrend</c>), sonst würde die Liste bei
|
||||
/// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb
|
||||
/// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden
|
||||
/// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow):
|
||||
/// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt
|
||||
/// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist
|
||||
/// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
|
||||
private const int CorrelationAbsenceRateThreshold = 15;
|
||||
private void BuildAttendanceParticipationNotices()
|
||||
{
|
||||
var students = _students.GetAll();
|
||||
foreach (var row in Roster.Where(r => r.HasYearSummary &&
|
||||
r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold))
|
||||
{
|
||||
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||||
var student = MatchStudent(row.StudentName, students);
|
||||
if (student is null) continue;
|
||||
|
||||
var points = _participation.GetByStudent(student.Id)
|
||||
.Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e))
|
||||
.Where(x => x.Session is not null && x.Entry.Ratings.Count > 0)
|
||||
.OrderBy(x => x.Session!.Date)
|
||||
.TakeLast(8)
|
||||
.Select(x => x.Entry.Ratings.Average(r => r.Value))
|
||||
.ToList();
|
||||
if (points.Count < 4) continue;
|
||||
|
||||
var mid = points.Count / 2;
|
||||
var diff = points.Skip(mid).Average() - points.Take(mid).Average();
|
||||
if (diff <= -0.4)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||||
$"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit",
|
||||
ClassTeacherStatusKind.Info));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
||||
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||||
/// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen).
|
||||
public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList<Student> students)
|
||||
{
|
||||
var key = UntisNameMatching.NameKey(webUntisDisplayName);
|
||||
return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key);
|
||||
}
|
||||
|
||||
private static string WeekdayLabel(DayOfWeek day) => day switch
|
||||
{
|
||||
DayOfWeek.Monday => "Montag",
|
||||
DayOfWeek.Tuesday => "Dienstag",
|
||||
DayOfWeek.Wednesday => "Mittwoch",
|
||||
DayOfWeek.Thursday => "Donnerstag",
|
||||
DayOfWeek.Friday => "Freitag",
|
||||
DayOfWeek.Saturday => "Samstag",
|
||||
_ => "Sonntag",
|
||||
};
|
||||
|
||||
/// Zeigt höchstens <see cref="OpenExcuseDisplayLimit"/> Zeilen — Nutzer-Feedback zu den
|
||||
/// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest
|
||||
/// zählt in <see cref="OpenExcuseOverflowCount"/> und wird als "+N weitere" angezeigt.
|
||||
private const int OpenExcuseDisplayLimit = 6;
|
||||
|
||||
private void BuildOpenExcuses(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today)
|
||||
{
|
||||
var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today);
|
||||
foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row);
|
||||
OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
DetailsTab.StudentFilter = row.StudentName;
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.LoadCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche
|
||||
/// Erstellen ("Eltern anrufen – Ada Müller") musste man dort noch einmal von Hand eintippen.
|
||||
/// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen —
|
||||
/// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe
|
||||
/// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist
|
||||
/// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben).
|
||||
[RelayCommand]
|
||||
private void CreateReminderForStudent(ClassTeacherRosterRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).",
|
||||
urgent: row.IsOverdue);
|
||||
}
|
||||
|
||||
private void SaveReminder(string studentName, string notes, bool urgent)
|
||||
{
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = $"Eltern kontaktieren – {studentName}",
|
||||
Notes = notes,
|
||||
Kind = TaskKind.Reminder,
|
||||
Priority = urgent ? TaskPriority.High : TaskPriority.Normal,
|
||||
DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1),
|
||||
};
|
||||
_workTasks.Save(task);
|
||||
Status = $"Wiedervorlage „{task.Title}\" angelegt — zu finden unter Aufgaben & Wiedervorlagen.";
|
||||
}
|
||||
|
||||
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
|
||||
private void NotifySummary()
|
||||
{
|
||||
@@ -452,6 +665,6 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
private void NotifyRosterState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
|
||||
OnPropertyChanged(nameof(HasNoFilterResults));
|
||||
OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,21 @@
|
||||
<Style Selector="Button.rosterRow:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.rosterRowAction">
|
||||
<Setter Property="Width" Value="30"/>
|
||||
<Setter Property="Height" Value="48"/>
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="0,0,1,1"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppAccentTextBrush}"/>
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<Style Selector="Button.rosterRowAction:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Border.sidePanel">
|
||||
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
|
||||
@@ -89,7 +104,13 @@
|
||||
<!-- Eine Vorlage für beide Roster-Listen (auffällig / übrige): Sie waren Zeichen für Zeichen
|
||||
identisch und mussten bisher doppelt gepflegt werden. -->
|
||||
<DataTemplate x:Key="RosterRowTemplate" x:DataType="vm:ClassTeacherRosterRow">
|
||||
<Button Classes="rosterRow"
|
||||
<!-- Zwei Geschwister-Controls statt eines ContextMenu auf dem Zeilen-Button: Avalonia löst
|
||||
$parent-Vorfahrenpfade in einem ContextMenu nicht zuverlässig auf, weil dessen Popup
|
||||
nicht im normalen visuellen Baum unter dem ItemsControl hängt. Ein separater kleiner
|
||||
Button daneben bleibt im normalen Baum und kann so ganz regulär auf das ViewModel des
|
||||
ItemsControl zugreifen. -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button Grid.Column="0" Classes="rosterRow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="4,42,220,*,150,18">
|
||||
@@ -127,6 +148,13 @@
|
||||
<TextBlock Grid.Column="5" Text="›" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
<!-- Quick-Win: "Eltern kontaktieren" als vorbefüllte Wiedervorlage direkt aus der Zeile
|
||||
anlegen, statt erst ins Aufgaben-Modul zu wechseln und von Hand einzutragen. -->
|
||||
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
|
||||
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren“ anlegen"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForStudentCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
@@ -369,6 +397,42 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/>
|
||||
<ItemsControl ItemsSource="{Binding OpenExcuses}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherOpenExcuseRow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button Grid.Column="0" Classes="rosterRow" Padding="6,5"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForOpenExcuseCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="4,*,Auto" Margin="0,2">
|
||||
<Border Grid.Column="0" CornerRadius="2" Margin="0,1,8,1"
|
||||
Classes="statusFill" Classes.warning="{Binding !IsOverdue}"
|
||||
Classes.danger="{Binding IsOverdue}"/>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding DaysOpenLabel}" FontSize="11" Opacity="0.62"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" FontSize="11"
|
||||
Opacity="0.5" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
|
||||
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren“ anlegen"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForOpenExcuseCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock FontSize="11" Opacity="0.5" IsVisible="{Binding HasOpenExcuseOverflow}">
|
||||
<Run Text="+"/><Run Text="{Binding OpenExcuseOverflowCount}"/><Run Text=" weitere"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="sidePanel" IsVisible="{Binding HasPatternNotices}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Muster erkannt" FontSize="14" FontWeight="SemiBold"/>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/>
|
||||
@@ -58,7 +58,35 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<!-- Nutzer-Feedback: die reine Gesamtzahl der Einträge sagt wenig, eine Aufschlüsselung nach
|
||||
Kategorie ("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller") ist die eigentlich
|
||||
interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
|
||||
bestehenden Spalten unangetastet bleiben. -->
|
||||
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding HasCategoryAggregates}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ClassTeacherCategoryAggregateRow">
|
||||
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="5"
|
||||
Padding="8,4" Margin="0,0,6,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock Text="{Binding CategoryName}" FontSize="11" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3">
|
||||
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
|
||||
@@ -77,6 +105,6 @@
|
||||
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
Reference in New Issue
Block a user