WIP (unstable): Klassenlehrer-Feature auf Einstellungen/eigenen Bereich umgestellt

Nutzer-Feedback: Klassenlehrer ist eine Rolle für die ganze Klasse, unabhängig vom
eigenen Unterricht - die Anbindung an ein LearningGroup.IsClassTeacher-Flag mit Tab
in der Gruppendetailansicht war deshalb der falsche Ort. Ersetzt durch:

- Klassenauswahl in den WebUntis-Einstellungen (WebUntisSettingsService.HomeroomClassName),
  über den bestehenden WebUntis-Klassenpicker.
- Eigener Sidebar-Bereich "Klassenlehrer" statt Gruppen-Tab.
- Zwei Ebenen: kompakte Roster-Übersicht mit Ampel-Symbolen (Fehlzeiten heute,
  Klassenbuch-Badge) und Details (die bisherigen Rohdaten-Listen, jetzt gefiltert
  auf einzelne Schüler*innen anspringbar).

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:30:34 +02:00
co-authored by Claude Sonnet 5
parent 5e16ebf1bf
commit 33c15ffe3e
21 changed files with 463 additions and 113 deletions
@@ -1,10 +1,9 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
@@ -57,19 +56,19 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
}
/// <summary>
/// Klassenlehrer-Tab: zeigt Klassenbucheinträge, die andere Lehrkräfte zu Schülern dieser Klasse
/// angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der eigenen
/// Benutzerkennung wie beim bestehenden Dokumentations-Abgleich, siehe
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich, siehe
/// <see cref="WebUntisIntegrationService.GetForeignClassRegisterEventsAsync"/>), sowie die
/// Fehlzeiten der Klasse über alle Fächer (<see cref="WebUntisIntegrationService.GetClassAbsencesAsync"/>).
/// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Nur für Gruppen mit
/// <see cref="Core.Models.LearningGroup.IsClassTeacher"/> sichtbar (siehe
/// GroupDetailViewModel/GroupDetailView).
/// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Bewusst unabhängig von jeder
/// <see cref="Core.Models.LearningGroup"/> — Klassenlehrer ist man für die ganze Klasse, nicht für
/// einen einzelnen Unterricht; die Klasse kommt aus <see cref="ClassTeacherOverviewViewModel"/>
/// (letztlich aus den WebUntis-Einstellungen), nicht aus einer Gruppen-ID.
/// </summary>
public partial class ClassTeacherTabViewModel : ObservableObject
public partial class ClassTeacherDetailsViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly IGroupRepository _groups;
private string _className = "";
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
@@ -79,15 +78,17 @@ public partial class ClassTeacherTabViewModel : ObservableObject
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
[ObservableProperty] private bool _busy;
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = "";
public ClassTeacherTabViewModel(WebUntisIntegrationService untis, IGroupRepository groups)
public ClassTeacherDetailsViewModel(WebUntisIntegrationService untis)
{
_untis = untis; _groups = groups;
_untis = untis;
}
public void Initialize(Guid groupId)
public void Initialize(string className)
{
_className = _groups.GetById(groupId)?.Name ?? "";
_className = className;
Entries.Clear();
AbsenceEntries.Clear();
Status = "Zeitraum wählen und laden.";
@@ -99,7 +100,7 @@ public partial class ClassTeacherTabViewModel : ObservableObject
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Dieser Gruppe fehlt ein Klassenname."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear();
try
@@ -109,6 +110,7 @@ public partial class ClassTeacherTabViewModel : ObservableObject
await Task.WhenAll(classRegisterTask, absencesTask);
var ordered = classRegisterTask.Result
.Where(MatchesStudentFilter)
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
@@ -118,7 +120,8 @@ public partial class ClassTeacherTabViewModel : ObservableObject
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
}
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result))
var absences = absencesTask.Result.Where(MatchesStudentFilter);
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row);
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
@@ -128,6 +131,14 @@ public partial class ClassTeacherTabViewModel : ObservableObject
finally { Busy = false; }
}
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) ||
string.Equals(entry.StudentName, StudentFilter, StringComparison.OrdinalIgnoreCase);
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) ||
string.Equals(entry.StudentName, StudentFilter, StringComparison.OrdinalIgnoreCase);
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
@@ -0,0 +1,135 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Eine Zeile der Klassenlehrer-Übersicht ("auf einen Blick", Nutzer-Feedback) - pro
/// Schüler*in ein Ampel-Symbol für "heute laut WebUntis keine gemeldete Fehlzeit" und ein Badge,
/// falls in den letzten Tagen ein Klassenbucheintrag einer anderen Lehrkraft angelegt wurde.
/// Bewusst kein Bezug zu eigenem Unterricht/eigener Anwesenheitserfassung - genau der Fall, den es
/// abzudecken gilt, ist der Tag ohne eigenen Unterricht mit der Klasse.</summary>
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{
public string StatusIcon => HasAbsenceToday ? "⚠" : "✓";
/// <summary>Führt die WebUntis-Schülerliste der Klasse mit den heutigen Fehlzeiten und den
/// jüngsten Klassenbucheinträgen zusammen. Zuordnung primär über <c>ExternKey</c> (beide
/// stammen aus WebUntis, gleiche Kennung), Fallback auf Namensgleichheit für den
/// Klassenbuch-Bericht, der keinen ExternKey liefert (siehe UntisForeignClassRegisterEventDto).</summary>
public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentDto> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries)
{
var absenceByKey = todayAbsences
.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value)
.ToDictionary(g => g.Key, g => g.First());
var absenceByName = todayAbsences
.GroupBy(a => a.StudentName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var recentNames = recentClassRegisterEntries
.Select(e => e.StudentName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return students
.Select(s =>
{
var absence = absenceByKey.GetValueOrDefault(s.ExternKey)
?? absenceByName.GetValueOrDefault(s.DisplayName);
return new ClassTeacherRosterRow(s.DisplayName, s.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
recentNames.Contains(s.DisplayName));
})
.OrderBy(r => r.StudentName)
.ToList();
}
}
/// <summary>
/// Top-Level-Bereich "Klassenlehrer" (eigener Sidebar-Eintrag, siehe MainWindowViewModel) - bewusst
/// nicht an eine LearningGroup gehängt, da man Klassenlehrer für eine ganze Klasse ist, unabhängig
/// vom eigenen Unterricht (Nutzer-Feedback zur ursprünglichen Umsetzung als Gruppen-Tab). Die
/// Klasse selbst kommt aus den WebUntis-Einstellungen (<see cref="WebUntisSettingsService.HomeroomClassName"/>).
/// Zwei Ebenen: Übersicht (dieses ViewModel, kompaktes Roster) und Details
/// (<see cref="ClassTeacherDetailsViewModel"/>, die bisherigen Rohdaten-Listen) - ein Klick auf
/// eine Roster-Zeile springt gefiltert in die Details.
/// </summary>
public partial class ClassTeacherOverviewViewModel : ObservableObject
{
private readonly WebUntisSettingsService _settings;
private readonly WebUntisIntegrationService _untis;
public ClassTeacherDetailsViewModel DetailsTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
[ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
/// Vom Code-Behind/MainWindowViewModel gesetzt: springt in die WebUntis-Einstellungen, wenn noch
/// keine Klasse gewählt ist.
public Func<Task>? OnNavigateToSettings { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
ClassTeacherDetailsViewModel detailsTab)
{
_settings = settings; _untis = untis; DetailsTab = detailsTab;
}
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
[RelayCommand]
private async Task Load()
{
HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0;
Roster.Clear();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
return;
}
var className = HomeroomClassName!;
DetailsTab.Initialize(className);
Busy = true;
try
{
var today = DateOnly.FromDateTime(DateTime.Today);
var studentsTask = _untis.GetStudentsAsync(className);
var absencesTask = _untis.GetClassAbsencesAsync(className, today, today);
var classRegisterTask = _untis.GetForeignClassRegisterEventsAsync(className, today.AddDays(-7), today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
var todayAbsences = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result.Students, todayAbsences,
classRegisterTask.Result))
Roster.Add(row);
Status = $"{Roster.Count} Schüler*innen · Stand heute.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
{
if (row is null) return;
DetailsTab.StudentFilter = row.StudentName;
ActiveTabIndex = 1;
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand]
private async Task GoToSettings()
{
if (OnNavigateToSettings is not null) await OnNavigateToSettings();
}
}
@@ -251,7 +251,6 @@ public partial class GroupDetailViewModel : ObservableObject
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
public bool IsClassTeacher => Group?.IsClassTeacher ?? false;
public bool IsReadOnly => Group is { IsActive: false };
public bool IsEditable => Group is { IsActive: true };
public string SubjectName { get; private set; } = "";
@@ -259,7 +258,6 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnGroupChanged(LearningGroup? value)
{
OnPropertyChanged(nameof(IsDifferentiated));
OnPropertyChanged(nameof(IsClassTeacher));
OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands();
@@ -281,7 +279,6 @@ public partial class GroupDetailViewModel : ObservableObject
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; }
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
public ClassTeacherTabViewModel ClassTeacherTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -298,8 +295,7 @@ public partial class GroupDetailViewModel : ObservableObject
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab,
ClassTeacherTabViewModel classTeacherTab)
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
@@ -310,7 +306,6 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
GroupDocumentationTab = groupDocumentationTab;
ClassTeacherTab = classTeacherTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
@@ -340,7 +335,6 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
GroupDocumentationTab.Initialize(Group.Id);
ClassTeacherTab.Initialize(Group.Id);
}
private void ReloadExams()
@@ -883,7 +877,6 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _isClassTeacher;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private string _nameError = "";
@@ -924,7 +917,6 @@ public partial class AddGroupDialogViewModel : ObservableObject
HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
IsClassTeacher = group.IsClassTeacher;
RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId;
OnPropertyChanged(nameof(DialogTitle));
@@ -972,7 +964,6 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
Result.IsClassTeacher = IsClassTeacher;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId;
_groups.Save(Result);
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
@@ -31,6 +32,7 @@ public partial class MainWindowViewModel : ObservableObject
public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public MainWindowViewModel(IServiceProvider services,
@@ -71,6 +73,7 @@ public partial class MainWindowViewModel : ObservableObject
case TimetableViewModel vm: vm.Load(); break;
case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
@@ -87,6 +90,7 @@ public partial class MainWindowViewModel : ObservableObject
OnPropertyChanged(nameof(IsExamsActive));
OnPropertyChanged(nameof(IsPlannerActive));
OnPropertyChanged(nameof(IsWorkloadActive));
OnPropertyChanged(nameof(IsClassTeacherActive));
OnPropertyChanged(nameof(IsSettingsActive));
}
@@ -102,6 +106,7 @@ public partial class MainWindowViewModel : ObservableObject
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
NavItem.Planner => GetTimetable(),
NavItem.Workload => GetWorkload(),
NavItem.ClassTeacher => GetClassTeacherOverview(),
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
_ => CurrentPage,
};
@@ -141,6 +146,14 @@ public partial class MainWindowViewModel : ObservableObject
return workload;
}
private ClassTeacherOverviewViewModel GetClassTeacherOverview()
{
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
vm.OnNavigateToSettings = () => { NavigateToSettings(SettingsTab.WebUntis); return Task.CompletedTask; };
vm.LoadCommand.Execute(null);
return vm;
}
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
{
ActiveNavItem = NavItem.Groups;
@@ -175,7 +188,7 @@ public partial class MainWindowViewModel : ObservableObject
public void NavigateToWorkload() => NavigateTo(NavItem.Workload);
}
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, ClassTeacher, Settings }
public partial class PlaceholderViewModel : ObservableObject
{
@@ -217,7 +217,14 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _untisPassword = "";
[ObservableProperty] private string _untisApiStatus = "";
[ObservableProperty] private bool _untisApiBusy;
[ObservableProperty] private string? _untisHomeroomClassName;
public string UntisHomeroomClassDisplay => UntisHomeroomClassName is { Length: > 0 } name
? $"Ausgewählt: {name}"
: "Keine Klasse ausgewählt.";
partial void OnUntisHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(UntisHomeroomClassDisplay));
public Func<Task>? OnReviewUntisMapping { get; set; }
/// Vom Code-Behind gesetzt: öffnet den WebUntis-Klassenauswahldialog, liefert null bei Abbruch.
public Func<Task<(int UntisId, string Name)?>>? OnPickHomeroomClass { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
@@ -559,6 +566,7 @@ public partial class SettingsViewModel : ObservableObject
UntisUsername = credentials.Username;
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
}
UntisHomeroomClassName = _untisSettings.HomeroomClassName;
}
[RelayCommand]
@@ -647,6 +655,23 @@ public partial class SettingsViewModel : ObservableObject
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
}
[RelayCommand]
private async Task UntisPickHomeroomClass()
{
if (OnPickHomeroomClass is null) return;
var result = await OnPickHomeroomClass();
if (result is null) return;
_untisSettings.SetHomeroomClass(result.Value.UntisId, result.Value.Name);
LoadUntisSettings();
}
[RelayCommand]
private void UntisClearHomeroomClass()
{
_untisSettings.SetHomeroomClass(null, null);
LoadUntisSettings();
}
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
private void LoadAnnualPlanSettings()