Kurs-Übersicht gefüllt: nächste Termine, Mitarbeitshinweis, offene Kontrollen/Dokumentationen, auffällige Fehlzeiten

Der bislang leere "Übersicht"-Tab je Kurs zeigt jetzt einen kompakten
"Was steht an"-Überblick aus bereits vorhandenen Daten mit Klick-Durchsprung
in den jeweiligen Tab: nächste Stunde/Klausur, Hinweis auf die letzte
Mitarbeitssitzung, offene Hausaufgaben-Kontrolle, offene Entschuldigungen,
offene Dokumentations-Entwürfe und auffällige Fehlzeiten.

Die Fehlzeiten-Karte ist bewusst kein 1:1-Abbild der Dashboard-Warnung: erst
ab einer Mindeststichprobe von 8 erfassten Terminen im Schuljahr gemeldet,
damit ein einzelner Fehltag zu Schuljahresbeginn nicht sofort als auffällig
gilt. Das Hauptdashboard hat dasselbe Problem noch, wird dort separat
nachgezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:39:31 +02:00
co-authored by Claude Sonnet 5
parent f73bd6c9ec
commit 4d2a89b14b
9 changed files with 683 additions and 8 deletions
+1
View File
@@ -249,6 +249,7 @@ public static class AppBootstrapper
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentDetailViewModel>();
services.AddTransient<GroupOverviewViewModel>();
services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<PlanningTabViewModel>();
@@ -0,0 +1,239 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Tab: Übersicht — kompakte "Was steht an"-Zusammenfassung eines Kurses ────
/// <summary>
/// Zieht keine eigenen neuen Daten — jede Karte liest nur, was die anderen Tabs (Planung,
/// Klausuren, Mitarbeit) ohnehin schon verwalten, und verweist per Klick dorthin. Bewusst kein
/// Ersatz für die Detail-Tabs, sondern ein schneller Überblick "was ist als nächstes dran /
/// was wurde vergessen".
/// </summary>
public partial class GroupOverviewViewModel : ObservableObject
{
private const int OpenExcuseMaxAgeDays = 21;
private const int HomeworkCheckLookbackDays = 120;
private const int NextLessonLookaheadDays = 90;
/// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine
/// hohe Fehlquote bewusst NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rein
/// rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dasselbe Problem hat aktuell noch die Fehlzeiten-Warnung im Hauptdashboard
/// (AttendanceBalanceService/AttendanceWarningItem) — bewusst unangetastet, wird dort separat
/// nachgezogen.
private const int AttendanceMinSampleSize = 8;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _schoolYear;
private Guid _groupId;
private string _groupName = "";
[ObservableProperty] private bool _hasNextLesson;
[ObservableProperty] private string _nextLessonLabel = "";
[ObservableProperty] private bool _hasNextExam;
[ObservableProperty] private string _nextExamLabel = "";
[ObservableProperty] private string _participationHintLabel = "";
[ObservableProperty] private bool _participationHintIsStale;
[ObservableProperty] private bool _hasOpenHomeworkCheck;
[ObservableProperty] private string _openHomeworkCheckLabel = "";
[ObservableProperty] private int _draftDocumentationCount;
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public bool HasOpenExcuses => OpenExcuses.Count > 0;
/// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
/// siehe <see cref="AttendanceMinSampleSize"/>.
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
public bool HasAttendanceNotices => AttendanceNotices.Count > 0;
/// Ein Delegate statt einem pro Karte, mit dem Ziel-Tab-Index von GroupDetailView.axaml als
/// Parameter (0 Übersicht, 1 Schüler, 2 Sitzpläne, 3 Mitarbeit, 4 Klausuren, 5 Noten,
/// 6 Planung, 7 Kompetenzen, 8 Dokumentation).
public Action<int>? OnNavigateToTab { get; set; }
[RelayCommand] private void NavigateToPlanning() => OnNavigateToTab?.Invoke(6);
[RelayCommand] private void NavigateToExams() => OnNavigateToTab?.Invoke(4);
[RelayCommand] private void NavigateToParticipation() => OnNavigateToTab?.Invoke(3);
[RelayCommand] private void NavigateToDocumentation() => OnNavigateToTab?.Invoke(8);
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
IParticipationSessionRepository sessions, IParticipationRepository entries,
IStudentRepository students, IDocumentationRepository documentation,
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
{
_lessons = lessons; _exams = exams; _sessions = sessions;
_entries = entries; _students = students; _documentation = documentation;
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
}
public void Initialize(Guid groupId, string groupName)
{
_groupId = groupId;
_groupName = groupName;
Refresh();
}
public void Refresh()
{
var today = DateOnly.FromDateTime(DateTime.Today);
LoadNextLesson(today);
LoadNextExam(today);
LoadParticipationHint(today);
LoadOpenHomeworkCheck(today);
LoadOpenExcuses(today);
LoadDraftDocumentationCount();
LoadAttendanceNotices(today);
}
private void LoadNextLesson(DateOnly today)
{
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
.Where(l => l.Status == LessonStatus.Planned)
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
.FirstOrDefault();
HasNextLesson = next is not null;
NextLessonLabel = next is null ? ""
: string.IsNullOrWhiteSpace(next.Topic)
? next.Date.ToString("dd.MM.yyyy")
: $"{next.Date:dd.MM.yyyy} — {next.Topic}";
}
private void LoadNextExam(DateOnly today)
{
var next = _exams.GetByGroup(_groupId).Where(e => e.Date >= today).MinBy(e => e.Date);
HasNextExam = next is not null;
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
}
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
private void LoadParticipationHint(DateOnly today)
{
var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault();
if (last is null)
{
ParticipationHintLabel = "Noch keine Mitarbeitssitzung angelegt.";
ParticipationHintIsStale = false;
return;
}
var daysAgo = today.DayNumber - last.Date.DayNumber;
ParticipationHintLabel = daysAgo <= 0
? "Letzte Sitzung: heute."
: $"Letzte Sitzung: {last.Date:dd.MM.yyyy} (vor {daysAgo} Tag(en)).";
ParticipationHintIsStale = daysAgo > 14;
}
/// Dieselbe Erkennung wie das Stundenplan-Badge "Hausaufgabe kontrollieren"
/// (TimetableViewModel.HasUnhandledHomework) — bewusst nur die unmittelbar letzte Lesson,
/// nicht die gesamte Historie.
private void LoadOpenHomeworkCheck(DateOnly today)
{
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault();
var open = previous is not null && !string.IsNullOrWhiteSpace(previous.Homework)
&& !previous.HomeworkChecked && !previous.HomeworkCheckDismissed;
HasOpenHomeworkCheck = open;
OpenHomeworkCheckLabel = open
? $"Aus der Stunde vom {previous!.Date:dd.MM.yyyy}: {previous.Homework}"
: "";
}
private void LoadOpenExcuses(DateOnly today)
{
OpenExcuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>();
foreach (var session in _sessions.GetByGroup(_groupId).Where(s => s.Date >= cutoff && s.Date <= today))
{
foreach (var entry in _entries.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, _groupName, session.Date);
item.OnResolve = ResolveExcuse;
items.Add(item);
}
}
foreach (var item in items.OrderBy(i => i.Date))
OpenExcuses.Add(item);
OnPropertyChanged(nameof(HasOpenExcuses));
}
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
{
var entry = _entries.GetBySessionAndStudent(item.SessionId, item.StudentId);
if (entry is null) return;
entry.Attendance = status;
_entries.Save(entry);
OpenExcuses.Remove(item);
OnPropertyChanged(nameof(HasOpenExcuses));
}
/// Entwürfe (5.1.1: schnelle Notiz beim Sitzplan, noch nicht zu einem echten Dokumentations-
/// eintrag ausformuliert) dieser Gruppe — dieselbe Zählung wie das Badge in
/// GroupDocumentationTabViewModel.Load, hier nur ohne die vollständige Entwurfsliste.
private void LoadDraftDocumentationCount()
{
DraftDocumentationCount = _students.GetByGroup(_groupId)
.SelectMany(s => _documentation.GetByStudent(s.Id))
.Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
}
/// Bewusst kein 1:1-Abbild der Dashboard-Fehlzeiten-Warnung: dieselbe Quote
/// (AttendanceBalanceService.WarningThresholdPercent), aber erst ab
/// <see cref="AttendanceMinSampleSize"/> erfassten Terminen, damit ein Fehltag in der ersten
/// Schulwoche nicht sofort als auffällig gilt.
private void LoadAttendanceNotices(DateOnly today)
{
AttendanceNotices.Clear();
var schoolYear = _schoolYear.CurrentSchoolYear(today);
var from = _schoolYear.SchoolYearStart(schoolYear);
var to = _schoolYear.SchoolYearEnd(schoolYear);
var entriesByStudent = new Dictionary<Guid, List<(DateOnly Date, AttendanceStatus? Status)>>();
foreach (var session in _sessions.GetByGroup(_groupId).Where(s => s.Date >= from && s.Date <= to))
{
foreach (var entry in _entries.GetBySession(session.Id))
{
if (!entriesByStudent.TryGetValue(entry.StudentId, out var list))
entriesByStudent[entry.StudentId] = list = [];
list.Add((session.Date, entry.Attendance));
}
}
var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetByGroup(_groupId))
{
if (!entriesByStudent.TryGetValue(student.Id, out var entries) || entries.Count < AttendanceMinSampleSize)
continue;
var balance = _attendanceBalance.Calculate(entries, from, to);
if (balance.ExceedsThreshold)
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
}
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
AttendanceNotices.Add(item);
OnPropertyChanged(nameof(HasAttendanceNotices));
}
}
@@ -208,12 +208,14 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
partial void OnActiveTabIndexChanged(int value)
{
if (value == 0) OverviewTab.Refresh();
if (value == 7) CompetencyOverviewTab.Refresh();
}
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
public GroupOverviewViewModel OverviewTab { get; }
public ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
@@ -233,12 +235,14 @@ public partial class GroupDetailViewModel : ObservableObject
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
OverviewTab = overviewTab;
ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab;
@@ -251,6 +255,7 @@ public partial class GroupDetailViewModel : ObservableObject
ParticipationTab.RefreshCurrentGrid();
};
SeatingPlanTab.OnDocumentationChanged = GroupDocumentationTab.Refresh;
OverviewTab.OnNavigateToTab = idx => ActiveTabIndex = idx;
}
public void LoadGroup(Guid id)
@@ -266,6 +271,7 @@ public partial class GroupDetailViewModel : ObservableObject
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015")}";
LoadStudents();
ReloadExams();
OverviewTab.Initialize(Group.Id, GroupTitle);
ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
PlanningTab.Initialize(Group.Id, IsReadOnly);
@@ -53,13 +53,7 @@
<!-- Tab: Übersicht -->
<ContentPage Header="Übersicht">
<ScrollViewer Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding GroupSubtitle}" Opacity="0.7" FontSize="14"/>
<TextBlock Text="Hier erscheint später eine Zusammenfassung der Lerngruppe."
Opacity="0.4"/>
</StackPanel>
</ScrollViewer>
<views:GroupOverviewTabView DataContext="{Binding OverviewTab}"/>
</ContentPage>
<!-- Tab: Schüler -->
@@ -0,0 +1,149 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vmRoot="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.Groups.GroupOverviewTabView"
x:DataType="vm:GroupOverviewViewModel">
<UserControl.Styles>
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAltHighBrush}"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="Margin" Value="0,0,12,12"/>
<Setter Property="MinWidth" Value="260"/>
<Setter Property="MaxWidth" Value="340"/>
</Style>
<Style Selector="TextBlock.cardTitle">
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Opacity" Value="0.5"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<Style Selector="TextBlock.cardBody.stale">
<Setter Property="Foreground" Value="#D97706"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Button.cardLink">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Margin" Value="0,10,0,0"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</UserControl.Styles>
<ScrollViewer Padding="20">
<StackPanel Spacing="0">
<WrapPanel>
<!-- Nächste Stunde -->
<Border Classes="card">
<StackPanel>
<TextBlock Text="NÄCHSTE STUNDE" Classes="cardTitle"/>
<TextBlock Text="{Binding NextLessonLabel}" TextWrapping="Wrap" Classes="cardBody"
IsVisible="{Binding HasNextLesson}"/>
<TextBlock Text="Keine geplante Stunde in Sicht." Classes="emptyhint"
IsVisible="{Binding !HasNextLesson}"/>
<Button Content="Zur Planung " Classes="cardLink" Command="{Binding NavigateToPlanningCommand}"/>
</StackPanel>
</Border>
<!-- Nächste Klausur -->
<Border Classes="card">
<StackPanel>
<TextBlock Text="NÄCHSTE KLAUSUR" Classes="cardTitle"/>
<TextBlock Text="{Binding NextExamLabel}" TextWrapping="Wrap" Classes="cardBody"
IsVisible="{Binding HasNextExam}"/>
<TextBlock Text="Keine Klausur geplant." Classes="emptyhint"
IsVisible="{Binding !HasNextExam}"/>
<Button Content="Zu den Klausuren " Classes="cardLink" Command="{Binding NavigateToExamsCommand}"/>
</StackPanel>
</Border>
<!-- Mitarbeit -->
<Border Classes="card">
<StackPanel>
<TextBlock Text="MITARBEIT" Classes="cardTitle"/>
<TextBlock Text="{Binding ParticipationHintLabel}" TextWrapping="Wrap"
Classes="cardBody" Classes.stale="{Binding ParticipationHintIsStale}"/>
<Button Content="Zur Mitarbeit " Classes="cardLink" Command="{Binding NavigateToParticipationCommand}"/>
</StackPanel>
</Border>
<!-- Offene Hausaufgaben-Kontrolle -->
<Border Classes="card" IsVisible="{Binding HasOpenHomeworkCheck}">
<StackPanel>
<TextBlock Text="OFFENE HAUSAUFGABEN-KONTROLLE" Classes="cardTitle"/>
<TextBlock Text="{Binding OpenHomeworkCheckLabel}" TextWrapping="Wrap" Classes="cardBody stale"/>
<Button Content="Zur Planung " Classes="cardLink" Command="{Binding NavigateToPlanningCommand}"/>
</StackPanel>
</Border>
<!-- Offene Dokumentationen -->
<Border Classes="card" IsVisible="{Binding !!DraftDocumentationCount}">
<StackPanel>
<TextBlock Text="OFFENE DOKUMENTATIONEN" Classes="cardTitle"/>
<TextBlock TextWrapping="Wrap" Classes="cardBody">
<Run Text="{Binding DraftDocumentationCount}"/>
<Run Text=" Entwurf/Entwürfe noch nicht ausformuliert."/>
</TextBlock>
<Button Content="Zur Dokumentation " Classes="cardLink" Command="{Binding NavigateToDocumentationCommand}"/>
</StackPanel>
</Border>
</WrapPanel>
<WrapPanel>
<!-- Offene Entschuldigungen -->
<Border Classes="card" MinWidth="340" MaxWidth="420"
IsVisible="{Binding HasOpenExcuses}">
<StackPanel>
<TextBlock Text="OFFENE ENTSCHULDIGUNGEN" Classes="cardTitle"/>
<ItemsControl ItemsSource="{Binding OpenExcuses}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vmRoot:OpenExcuseItem">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding DateDisplay}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Entschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkExcusedCommand}" Margin="0,0,4,0"/>
<Button Grid.Column="2" Content="Unentschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkUnexcusedCommand}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Auffällige Fehlzeiten: bewusst zurückhaltender formuliert/gestylt als die
Fehlzeiten-Warnung im Hauptdashboard, siehe GroupOverviewViewModel.AttendanceMinSampleSize. -->
<Border Classes="card" MinWidth="340" MaxWidth="420"
IsVisible="{Binding HasAttendanceNotices}">
<StackPanel>
<TextBlock Text="AUFFÄLLIGE FEHLZEITEN" Classes="cardTitle"/>
<ItemsControl ItemsSource="{Binding AttendanceNotices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vmRoot:AttendanceWarningItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding StudentName}" FontSize="13"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" FontSize="12" Foreground="#D97706" FontWeight="SemiBold"
VerticalAlignment="Center">
<Run Text="{Binding AbsenceRatePercent, StringFormat={}{0:0.#}}"/>
<Run Text=" %"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</WrapPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupOverviewTabView : UserControl
{
public GroupOverviewTabView() => InitializeComponent();
}