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
-7
View File
@@ -14,13 +14,6 @@ public class LearningGroup
public bool IsOwnClass { get; set; } public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; } public bool IsDifferentiated { get; set; }
/// <summary> /// <summary>
/// Ich bin Klassenlehrer/-in dieser Klasse. Anders als <see cref="IsOwnClass"/> (rein
/// kalenderfarbliche Markierung, mehrfach setzbar für Klasse + zugehörige Kurse) schaltet
/// dieses Flag Zugriff auf Daten anderer Lehrkräfte zu dieser Klasse frei (Klassenlehrer-Tab)
/// und sollte deshalb nur an genau einer Klasse-Gruppe gesetzt werden.
/// </summary>
public bool IsClassTeacher { get; set; }
/// <summary>
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher /// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder /// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten /// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
@@ -1,5 +1,5 @@
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -62,4 +62,48 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date); Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date); Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
} }
[Fact]
public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu()
{
var students = new[]
{
Student(1001, "Ada Müller"),
Student(1002, "Ben Schmidt"),
Student(1003, "Cem Yilmaz"),
};
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90,
["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
Assert.Equal(3, roster.Count);
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.True(ada.HasAbsenceToday);
Assert.Contains("nicht entsch.", ada.AbsenceTooltip);
Assert.False(ada.HasRecentClassRegisterEntry);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.False(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz");
Assert.False(cem.HasAbsenceToday);
Assert.False(cem.HasRecentClassRegisterEntry);
}
private static UntisStudentDto Student(int externKey, string displayName) => new(
UntisId: externKey, ExternKey: externKey, ClassName: "6a", Name: displayName, LongName: null,
ForeName: null, DisplayName: displayName, Gender: null, BirthDate: null, BirthDateRaw: null,
EntryDate: null, EntryDateRaw: null, ExitDate: null, ExitDateRaw: null, Text: null,
MedicalReportDuty: null, Schulpflicht: null, Majority: null,
Address: new UntisStudentAddressDto(null, null, null, null, null, null), AttributeIL: null);
} }
@@ -1,6 +1,5 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Xunit; using Xunit;
@@ -30,9 +29,7 @@ public sealed class GroupDetailViewModelTests
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()), new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships, new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()), new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups), new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
new ClassTeacherTabViewModel(
new WebUntisIntegrationService(new HttpClient(), TestSupport.BuildWebUntisSettingsService()), groups));
vm.LoadGroup(group.Id); vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
+3 -1
View File
@@ -5,6 +5,7 @@ using LehrerApp.Data;
using LehrerApp.Data.Repositories; using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
@@ -297,6 +298,8 @@ public static class AppBootstrapper
services.AddSingleton<TimeTrackingViewModel>(); services.AddSingleton<TimeTrackingViewModel>();
services.AddSingleton<WorkloadEvaluationViewModel>(); services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>(); services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten) // Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>(); services.AddTransient<GroupDetailViewModel>();
@@ -308,7 +311,6 @@ public static class AppBootstrapper
services.AddTransient<CompetencyOverviewTabViewModel>(); services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<SeatingPlanTabViewModel>(); services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>(); services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<ClassTeacherTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<TrashViewModel>(); services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
@@ -11,6 +11,8 @@ internal class WebUntisSettingsConfig
public string LastSyncStatus { get; set; } = ""; public string LastSyncStatus { get; set; } = "";
public string? EncryptedApiCredentials { get; set; } public string? EncryptedApiCredentials { get; set; }
public int? TeacherUntisId { get; set; } public int? TeacherUntisId { get; set; }
public int? HomeroomClassUntisId { get; set; }
public string? HomeroomClassName { get; set; }
} }
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password); public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
@@ -36,6 +38,8 @@ public class WebUntisSettingsService
public bool IsConfigured => _config.EncryptedIcalUrl is not null; public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null; public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
public int? TeacherUntisId => _config.TeacherUntisId; public int? TeacherUntisId => _config.TeacherUntisId;
public int? HomeroomClassUntisId => _config.HomeroomClassUntisId;
public string? HomeroomClassName => _config.HomeroomClassName;
public DateTime? LastSyncAt => _config.LastSyncAt; public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus; public string LastSyncStatus => _config.LastSyncStatus;
@@ -83,6 +87,8 @@ public class WebUntisSettingsService
{ {
_config.EncryptedApiCredentials = null; _config.EncryptedApiCredentials = null;
_config.TeacherUntisId = null; _config.TeacherUntisId = null;
_config.HomeroomClassUntisId = null;
_config.HomeroomClassName = null;
Save(); Save();
} }
@@ -92,6 +98,16 @@ public class WebUntisSettingsService
Save(); Save();
} }
// Die Klasse, deren Klassenlehrer/-in man ist - unabhängig von jeder LearningGroup (siehe
// TODO.md "Klassenlehrer"-Feature): man ist es für die ganze Klasse, nicht für einen einzelnen
// Unterricht/Kurs. HomeroomClassName ist wie TeacherUntisId unverschlüsselt, da kein Geheimnis.
public void SetHomeroomClass(int? untisId, string? name)
{
_config.HomeroomClassUntisId = untisId;
_config.HomeroomClassName = name;
Save();
}
public void SetLastSync(DateTime at, string status) public void SetLastSync(DateTime at, string status)
{ {
_config.LastSyncAt = at; _config.LastSyncAt = at;
@@ -1,10 +1,9 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services; 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. /// <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 /// 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> /// <summary>
/// Klassenlehrer-Tab: zeigt Klassenbucheinträge, die andere Lehrkräfte zu Schülern dieser Klasse /// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
/// angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der eigenen /// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
/// Benutzerkennung wie beim bestehenden Dokumentations-Abgleich, siehe /// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich, siehe
/// <see cref="WebUntisIntegrationService.GetForeignClassRegisterEventsAsync"/>), sowie die /// <see cref="WebUntisIntegrationService.GetForeignClassRegisterEventsAsync"/>), sowie die
/// Fehlzeiten der Klasse über alle Fächer (<see cref="WebUntisIntegrationService.GetClassAbsencesAsync"/>). /// 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 /// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Bewusst unabhängig von jeder
/// <see cref="Core.Models.LearningGroup.IsClassTeacher"/> sichtbar (siehe /// <see cref="Core.Models.LearningGroup"/> — Klassenlehrer ist man für die ganze Klasse, nicht für
/// GroupDetailViewModel/GroupDetailView). /// einen einzelnen Unterricht; die Klasse kommt aus <see cref="ClassTeacherOverviewViewModel"/>
/// (letztlich aus den WebUntis-Einstellungen), nicht aus einer Gruppen-ID.
/// </summary> /// </summary>
public partial class ClassTeacherTabViewModel : ObservableObject public partial class ClassTeacherDetailsViewModel : ObservableObject
{ {
private readonly WebUntisIntegrationService _untis; private readonly WebUntisIntegrationService _untis;
private readonly IGroupRepository _groups;
private string _className = ""; private string _className = "";
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = []; public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
@@ -79,15 +78,17 @@ public partial class ClassTeacherTabViewModel : ObservableObject
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und laden."; [ObservableProperty] private string _status = "Zeitraum wählen und laden.";
[ObservableProperty] private bool _busy; [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(); Entries.Clear();
AbsenceEntries.Clear(); AbsenceEntries.Clear();
Status = "Zeitraum wählen und laden."; Status = "Zeitraum wählen und laden.";
@@ -99,7 +100,7 @@ public partial class ClassTeacherTabViewModel : ObservableObject
var start = DateOnly.FromDateTime(StartDate.LocalDateTime); var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime); var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } 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(); Busy = true; Entries.Clear(); AbsenceEntries.Clear();
try try
@@ -109,6 +110,7 @@ public partial class ClassTeacherTabViewModel : ObservableObject
await Task.WhenAll(classRegisterTask, absencesTask); await Task.WhenAll(classRegisterTask, absencesTask);
var ordered = classRegisterTask.Result var ordered = classRegisterTask.Result
.Where(MatchesStudentFilter)
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null)) .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null) .Where(x => x.Date is not null)
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName); .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)); 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); AbsenceEntries.Add(row);
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " + Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
@@ -128,6 +131,14 @@ public partial class ClassTeacherTabViewModel : ObservableObject
finally { Busy = false; } 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) => private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out 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 // 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. // kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false; public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
public bool IsClassTeacher => Group?.IsClassTeacher ?? false;
public bool IsReadOnly => Group is { IsActive: false }; public bool IsReadOnly => Group is { IsActive: false };
public bool IsEditable => Group is { IsActive: true }; public bool IsEditable => Group is { IsActive: true };
public string SubjectName { get; private set; } = ""; public string SubjectName { get; private set; } = "";
@@ -259,7 +258,6 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnGroupChanged(LearningGroup? value) partial void OnGroupChanged(LearningGroup? value)
{ {
OnPropertyChanged(nameof(IsDifferentiated)); OnPropertyChanged(nameof(IsDifferentiated));
OnPropertyChanged(nameof(IsClassTeacher));
OnPropertyChanged(nameof(IsReadOnly)); OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable)); OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands(); NotifyWriteCommands();
@@ -281,7 +279,6 @@ public partial class GroupDetailViewModel : ObservableObject
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; } public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; } public SeatingPlanTabViewModel SeatingPlanTab { get; }
public GroupDocumentationTabViewModel GroupDocumentationTab { get; } public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
public ClassTeacherTabViewModel ClassTeacherTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; } public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; } public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; } public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -298,8 +295,7 @@ public partial class GroupDetailViewModel : ObservableObject
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab, GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
GradeOverviewTabViewModel gradeOverviewTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab, PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab, SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
ClassTeacherTabViewModel classTeacherTab)
{ {
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks; _exams = exams; _grades = grades; _tasks = tasks;
@@ -310,7 +306,6 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab = competencyOverviewTab; CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab; SeatingPlanTab = seatingPlanTab;
GroupDocumentationTab = groupDocumentationTab; GroupDocumentationTab = groupDocumentationTab;
ClassTeacherTab = classTeacherTab;
SeatingPlanTab.OnAssessmentChanged = () => SeatingPlanTab.OnAssessmentChanged = () =>
{ {
ParticipationTab.LoadSessions(); ParticipationTab.LoadSessions();
@@ -340,7 +335,6 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab.Initialize(Group); CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly); SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
GroupDocumentationTab.Initialize(Group.Id); GroupDocumentationTab.Initialize(Group.Id);
ClassTeacherTab.Initialize(Group.Id);
} }
private void ReloadExams() private void ReloadExams()
@@ -883,7 +877,6 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private int? _hoursPerWeek; [ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass; [ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated; [ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _isClassTeacher;
[ObservableProperty] private bool _requiresLessonPlanning = true; [ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private int? _webUntisLessonId; [ObservableProperty] private int? _webUntisLessonId;
[ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _nameError = "";
@@ -924,7 +917,6 @@ public partial class AddGroupDialogViewModel : ObservableObject
HoursPerWeek = group.HoursPerWeek; HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass; IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated; IsDifferentiated = group.IsDifferentiated;
IsClassTeacher = group.IsClassTeacher;
RequiresLessonPlanning = group.RequiresLessonPlanning; RequiresLessonPlanning = group.RequiresLessonPlanning;
WebUntisLessonId = group.WebUntisLessonId; WebUntisLessonId = group.WebUntisLessonId;
OnPropertyChanged(nameof(DialogTitle)); OnPropertyChanged(nameof(DialogTitle));
@@ -972,7 +964,6 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.HoursPerWeek = HoursPerWeek; Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass; Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated; Result.IsDifferentiated = IsDifferentiated;
Result.IsClassTeacher = IsClassTeacher;
Result.RequiresLessonPlanning = RequiresLessonPlanning; Result.RequiresLessonPlanning = RequiresLessonPlanning;
Result.WebUntisLessonId = WebUntisLessonId; Result.WebUntisLessonId = WebUntisLessonId;
_groups.Save(Result); _groups.Save(Result);
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
@@ -31,6 +32,7 @@ public partial class MainWindowViewModel : ObservableObject
public bool IsExamsActive => ActiveNavItem == NavItem.Exams; public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner; public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload; public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings; public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public MainWindowViewModel(IServiceProvider services, public MainWindowViewModel(IServiceProvider services,
@@ -71,6 +73,7 @@ public partial class MainWindowViewModel : ObservableObject
case TimetableViewModel vm: vm.Load(); break; case TimetableViewModel vm: vm.Load(); break;
case WorkloadViewModel vm: case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break; 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; case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht // Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog. // laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
@@ -87,6 +90,7 @@ public partial class MainWindowViewModel : ObservableObject
OnPropertyChanged(nameof(IsExamsActive)); OnPropertyChanged(nameof(IsExamsActive));
OnPropertyChanged(nameof(IsPlannerActive)); OnPropertyChanged(nameof(IsPlannerActive));
OnPropertyChanged(nameof(IsWorkloadActive)); OnPropertyChanged(nameof(IsWorkloadActive));
OnPropertyChanged(nameof(IsClassTeacherActive));
OnPropertyChanged(nameof(IsSettingsActive)); OnPropertyChanged(nameof(IsSettingsActive));
} }
@@ -102,6 +106,7 @@ public partial class MainWindowViewModel : ObservableObject
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" }, NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
NavItem.Planner => GetTimetable(), NavItem.Planner => GetTimetable(),
NavItem.Workload => GetWorkload(), NavItem.Workload => GetWorkload(),
NavItem.ClassTeacher => GetClassTeacherOverview(),
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(), NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
_ => CurrentPage, _ => CurrentPage,
}; };
@@ -141,6 +146,14 @@ public partial class MainWindowViewModel : ObservableObject
return workload; 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) public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
{ {
ActiveNavItem = NavItem.Groups; ActiveNavItem = NavItem.Groups;
@@ -175,7 +188,7 @@ public partial class MainWindowViewModel : ObservableObject
public void NavigateToWorkload() => NavigateTo(NavItem.Workload); 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 public partial class PlaceholderViewModel : ObservableObject
{ {
@@ -217,7 +217,14 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _untisPassword = ""; [ObservableProperty] private string _untisPassword = "";
[ObservableProperty] private string _untisApiStatus = ""; [ObservableProperty] private string _untisApiStatus = "";
[ObservableProperty] private bool _untisApiBusy; [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; } 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) ───────────────────────────── // ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
@@ -559,6 +566,7 @@ public partial class SettingsViewModel : ObservableObject
UntisUsername = credentials.Username; UntisUsername = credentials.Username;
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert."; UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
} }
UntisHomeroomClassName = _untisSettings.HomeroomClassName;
} }
[RelayCommand] [RelayCommand]
@@ -647,6 +655,23 @@ public partial class SettingsViewModel : ObservableObject
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping(); 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 ─ // ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
private void LoadAnnualPlanSettings() private void LoadAnnualPlanSettings()
@@ -1,10 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.Groups.ClassTeacherTabView" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherDetailsView"
x:DataType="vm:ClassTeacherTabViewModel"> x:DataType="vm:ClassTeacherDetailsViewModel">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,1*,Auto,Auto,1*,Auto" Margin="16" RowSpacing="6"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,1*,Auto,Auto,1*,Auto" Margin="16" RowSpacing="6">
<TextBlock Grid.Row="0" TextWrapping="Wrap" FontSize="12" Opacity="0.65" <TextBlock Grid.Row="0" TextWrapping="Wrap" FontSize="12" Opacity="0.65"
Text="Klassenbucheinträge anderer Lehrkräfte und Fehlzeiten der Klasse über alle Fächer (nur Ansicht, keine Übernahme in eigene Daten)."/> Text="Klassenbucheinträge anderer Lehrkräfte und Fehlzeiten der Klasse über alle Fächer (nur Ansicht, keine Übernahme in eigene Daten)."/>
@@ -15,8 +15,14 @@
<Button Content="Laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Content="Laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel> </StackPanel>
<TextBlock Grid.Row="2" Text="Klassenbucheinträge anderer Lehrkräfte" FontWeight="SemiBold" FontSize="13" Margin="0,4,0,0"/> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
<Grid Grid.Row="3" ColumnDefinitions="70,1*,1.1*,0.8*,1*,1.3*" ColumnSpacing="8" Margin="4,0"> IsVisible="{Binding StudentFilter, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding StudentFilter, StringFormat='Gefiltert auf: {0}'}" FontSize="12" VerticalAlignment="Center"/>
<Button Content="Filter zurücksetzen" FontSize="11" Padding="8,3" Click="OnClearStudentFilterClick"/>
</StackPanel>
<TextBlock Grid.Row="3" Text="Klassenbucheinträge anderer Lehrkräfte" FontWeight="SemiBold" FontSize="13" Margin="0,4,0,0"/>
<Grid Grid.Row="4" ColumnDefinitions="70,1*,1.1*,0.8*,1*,1.3*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="0" Text="Datum" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="0" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Schüler*in" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="Schüler*in" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Fach / Lehrkraft" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="2" Text="Fach / Lehrkraft" FontSize="11" Opacity="0.6"/>
@@ -24,7 +30,7 @@
<TextBlock Grid.Column="4" Text="Gruppe" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="4" Text="Gruppe" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Text" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="5" Text="Text" FontSize="11" Opacity="0.6"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="4"> <ScrollViewer Grid.Row="5">
<ItemsControl ItemsSource="{Binding Entries}"> <ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherClassRegisterRow"> <DataTemplate x:DataType="vm:ClassTeacherClassRegisterRow">
@@ -44,9 +50,9 @@
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
<TextBlock Grid.Row="5" Text="Fehlzeiten der Klasse (alle Fächer, pro Schüler*in und Tag zusammengefasst)" <TextBlock Grid.Row="6" Text="Fehlzeiten der Klasse (alle Fächer, pro Schüler*in und Tag zusammengefasst)"
FontWeight="SemiBold" FontSize="13" Margin="0,8,0,0"/> FontWeight="SemiBold" FontSize="13" Margin="0,8,0,0"/>
<Grid Grid.Row="6" ColumnDefinitions="70,1*,1.3*,0.5*,0.6*,0.6*,0.8*,1.5*" ColumnSpacing="8" Margin="4,0"> <Grid Grid.Row="7" ColumnDefinitions="70,1*,1.3*,0.5*,0.6*,0.6*,0.8*,1.5*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="0" Text="Datum" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="0" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Schüler*in" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="Schüler*in" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Fächer" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="2" Text="Fächer" FontSize="11" Opacity="0.6"/>
@@ -56,7 +62,7 @@
<TextBlock Grid.Column="6" Text="Status" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="6" Text="Status" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Grund / Notiz" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Column="7" Text="Grund / Notiz" FontSize="11" Opacity="0.6"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="7"> <ScrollViewer Grid.Row="8">
<ItemsControl ItemsSource="{Binding AbsenceEntries}"> <ItemsControl ItemsSource="{Binding AbsenceEntries}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow"> <DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
@@ -79,6 +85,6 @@
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
<TextBlock Grid.Row="8" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/> <TextBlock Grid.Row="9" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherDetailsView : UserControl
{
public ClassTeacherDetailsView() => InitializeComponent();
private void OnClearStudentFilterClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not ClassTeacherDetailsViewModel vm) return;
vm.StudentFilter = "";
vm.LoadCommand.Execute(null);
}
}
@@ -0,0 +1,59 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
x:DataType="vm:ClassTeacherOverviewViewModel">
<Grid>
<!-- Hinweis, solange in den Einstellungen keine Klasse ausgewählt ist -->
<StackPanel IsVisible="{Binding !HomeroomClassConfigured}" HorizontalAlignment="Center"
VerticalAlignment="Center" Spacing="12" MaxWidth="360">
<TextBlock Text="🎓" FontSize="40" HorizontalAlignment="Center"/>
<TextBlock Text="Noch keine Klasse ausgewählt" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock TextWrapping="Wrap" TextAlignment="Center" FontSize="13" Opacity="0.7"
Text="Wähle in den WebUntis-Einstellungen die Klasse aus, deren Klassenlehrer/-in du bist."/>
<Button Content="Zu den Einstellungen" Command="{Binding GoToSettingsCommand}" HorizontalAlignment="Center"/>
</StackPanel>
<TabbedPage IsVisible="{Binding HomeroomClassConfigured}" TabPlacement="Top"
SelectedIndex="{Binding ActiveTabIndex}">
<ContentPage Header="Übersicht">
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="8">
<TextBlock Grid.Row="0" Text="{Binding HomeroomClassName, StringFormat='Klasse {0}'}"
FontSize="18" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Text="{Binding Status}" FontSize="12" Opacity="0.7"/>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Roster}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherRosterRow">
<Button HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Background="Transparent" BorderThickness="0" Padding="8,6"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusIcon}" FontSize="16" Width="28"
ToolTip.Tip="{Binding AbsenceTooltip}"/>
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center"/>
<Border Grid.Column="2" Background="#1976D2" CornerRadius="8" Padding="6,1"
IsVisible="{Binding HasRecentClassRegisterEntry}"
ToolTip.Tip="Klassenbucheintrag einer anderen Lehrkraft in den letzten 7 Tagen">
<TextBlock Text="Klassenbuch" Foreground="White" FontSize="10"/>
</Border>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</ContentPage>
<ContentPage Header="Details">
<views:ClassTeacherDetailsView DataContext="{Binding DetailsTab}"/>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherOverviewView : UserControl
{
public ClassTeacherOverviewView() => InitializeComponent();
}
@@ -71,9 +71,6 @@
<CheckBox Content="Gehört zu meiner Klasse" IsChecked="{Binding IsOwnClass}" <CheckBox Content="Gehört zu meiner Klasse" IsChecked="{Binding IsOwnClass}"
ToolTip.Tip="Für die farbliche Hervorhebung im Dashboard-Kalender. Mehrere Gruppen (Klasse + zugehörige Kurse) können markiert werden."/> ToolTip.Tip="Für die farbliche Hervorhebung im Dashboard-Kalender. Mehrere Gruppen (Klasse + zugehörige Kurse) können markiert werden."/>
<CheckBox Content="Klassenlehrer/-in dieser Klasse" IsChecked="{Binding IsClassTeacher}"
ToolTip.Tip="Blendet den Klassenlehrer-Tab ein (Klassenbucheinträge anderer Lehrkräfte zu dieser Klasse). Sollte nur an genau einer Gruppe gesetzt werden."/>
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}" <CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/> ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
@@ -1,8 +0,0 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
public partial class ClassTeacherTabView : UserControl
{
public ClassTeacherTabView() => InitializeComponent();
}
@@ -205,11 +205,6 @@
<views:GroupDocumentationTabView DataContext="{Binding GroupDocumentationTab}"/> <views:GroupDocumentationTabView DataContext="{Binding GroupDocumentationTab}"/>
</ContentPage> </ContentPage>
<!-- Tab: Klassenlehrer, nur sichtbar für Gruppen mit IsClassTeacher -->
<ContentPage Header="Klassenlehrer" IsVisible="{Binding IsClassTeacher}">
<views:ClassTeacherTabView DataContext="{Binding ClassTeacherTab}"/>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
+15
View File
@@ -13,6 +13,8 @@
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning" xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.Workload" xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.Workload"
xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload" xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
xmlns:vct="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
xmlns:vmct="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services" xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.MainWindow" x:Class="LehrerApp.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
@@ -58,6 +60,9 @@
<DataTemplate DataType="vmw:WorkloadViewModel"> <DataTemplate DataType="vmw:WorkloadViewModel">
<vw:WorkloadView/> <vw:WorkloadView/>
</DataTemplate> </DataTemplate>
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
<vct:ClassTeacherOverviewView/>
</DataTemplate>
<DataTemplate DataType="vm:PlaceholderViewModel"> <DataTemplate DataType="vm:PlaceholderViewModel">
<views:PlaceholderView/> <views:PlaceholderView/>
</DataTemplate> </DataTemplate>
@@ -209,6 +214,16 @@
<TextBlock Classes="navlabel" Text="Arbeitszeit"/> <TextBlock Classes="navlabel" Text="Arbeitszeit"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Classes="navitem" Classes.active="{Binding IsClassTeacherActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.ClassTeacher}">
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="🎓" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Klassenlehrer"/>
</StackPanel>
</Button>
<Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch" <Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" HorizontalContentAlignment="Left"
CornerRadius="6" CornerRadius="6"
@@ -1000,6 +1000,19 @@
IsVisible="{Binding UntisApiIsConfigured}" IsEnabled="{Binding !UntisApiBusy}"/> IsVisible="{Binding UntisApiIsConfigured}" IsEnabled="{Binding !UntisApiBusy}"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding UntisApiIsConfigured}">
<Separator Margin="0,8"/>
<TextBlock Text="Klassenlehrer" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Die Klasse, deren Klassenlehrer/-in du bist — unabhängig von deinem eigenen Unterricht. Schaltet den Klassenlehrer-Bereich in der Seitenleiste frei."/>
<TextBlock FontSize="13" Text="{Binding UntisHomeroomClassDisplay}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Klasse auswählen…" Command="{Binding UntisPickHomeroomClassCommand}"/>
<Button Content="Entfernen" Command="{Binding UntisClearHomeroomClassCommand}"
IsVisible="{Binding UntisHomeroomClassName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
<Separator Margin="0,8"/> <Separator Margin="0,8"/>
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/> <TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
@@ -4,8 +4,10 @@ using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Planning; using LehrerApp.Desktop.Views.Planning;
using LehrerApp.Desktop.Views.Shared; using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -31,6 +33,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog; vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme; vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog; vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
vm.OnPickHomeroomClass = PickHomeroomClassAsync;
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null); _ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
} }
} }
@@ -51,6 +54,20 @@ public partial class SettingsView : UserControl
await dialog.ShowDialog<bool>(owner); await dialog.ShowDialog<bool>(owner);
} }
private async Task<(int UntisId, string Name)?> PickHomeroomClassAsync()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var untis = App.Services.GetRequiredService<WebUntisIntegrationService>();
var selectionVm = new WebUntisClassSelectionViewModel(untis);
var selection = new WebUntisClassSelectionDialog { DataContext = selectionVm };
await selectionVm.InitializeAsync();
if (!await selection.ShowDialog<bool>(owner) || selectionVm.SelectedClass is null) return null;
return (selectionVm.SelectedClass.UntisId, selectionVm.SelectedClass.Name);
}
private async Task<bool> SaveRecoveryFile(string content) private async Task<bool> SaveRecoveryFile(string content)
{ {
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
+50 -46
View File
@@ -1277,53 +1277,57 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler. Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut. Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
- [x] **"Klassenlehrer"-Feature, Schritt 1 (Klassenbuch fremder Einträge, August 2026):** Der - [x] **"Klassenlehrer"-Feature — Datenschicht (August 2026):** Zwei WebUntis-Berichte liefern, was
"-alle-"-Bericht enthält für die eigene Klasse auch Einträge, die andere Lehrkräfte in deren für einen Klassenlehrer-Überblick fehlte. `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync`
Fächern angelegt haben (`Benutzer` ≠ eigener Login) — für den reinen Dokumentations-Abgleich ist das Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht
(eigene Einträge synchron halten) werden die herausgefiltert. Neues Flag (`ClassregEventPerStudent`), aber Einträge anderer Lehrkräfte (`Benutzer` ≠ eigener Login) statt
`LearningGroup.IsClassTeacher` ("Klassenlehrer/-in dieser Klasse", Stammdaten-Checkbox, keine der eigenen, auf eine Klasse eingeschränkt — kein neuer Bericht nötig.
Migration nötig) blendet einen neuen Tab "Klassenlehrer" in der Gruppendetailansicht ein — `GetClassAbsencesAsync`/`reports.do?name=AbsencePerStudent&klasseOrStudentgroupId=KL<id>&studentId=-1`
bewusst ein eigenes Flag statt `IsOwnClass` (das ist rein die Kalenderfarb-Markierung und (per Netzwerk-Mitschnitt gefunden) liefert Fehlzeiten der ganzen Klasse über alle Fächer, nicht nur
mehrfach setzbar für Klasse + zugehörige Kurse, würde den Tab sonst versehentlich auch bei eigenen Unterricht. `klasseOrStudentgroupId` ist `"KL"` + die `getKlassen`-interne Klassen-ID
Kursen zeigen). `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync` ist das (dasselbe Element-Kürzel-Schema wie `kl`/`te`/`su`/`ro` bei anderen Berichten) —
Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht, aber Einträge `WebUntisIntegrationService.ResolveClassIdAsync` löst sie pro Aufruf über
anderer Lehrkräfte statt der eigenen, zusätzlich auf die übergebene Klasse eingeschränkt (kein `getSchoolyears`+`getKlassen` und den Klassennamen auf. Das CSV-Format ist **nicht** identisch mit
neuer WebUntis-Bericht nötig). `ClassTeacherTabViewModel`/`ClassTeacherTabView` zeigen die `AbsencePerLesson` (ursprüngliche Vermutung anhand echter Daten widerlegt) — eigenes Spaltenschema,
Liste rein lesend an (Datum, Schüler*in, Fach/Lehrkraft, Kategorie, Text) — kein eine Zeile pro Fehlstunde, eigener Parser `WebUntisClassAbsenceParser`/Modell
"Übernehmen"-Command wie beim Dokumentations-Abgleich, da es fremde Einträge sind, keine `UntisClassAbsenceEntry`. Anders als `AbsencePerLesson` liefert `Status` hier einen Klartext-Wert
eigenen zum lokalen Synchronisieren. direkt (im Sample nur `"nicht entsch."` beobachtet, andere Werte nicht bestätigt), es braucht also
**Nicht Teil dieses Schritts:** Fehlzeiten der Klasse aus fremdem Unterricht — dafür fehlt noch keine Status-Heuristik. Der Header `Text` kommt zweimal vor (eine immer-leere Spalte, dann die
ein per Netzwerk-Mitschnitt zu ermittelnder klassenbezogener Fehlzeiten-Bericht ohne `lessonId` eigentliche Tagesnotiz) — der Dictionary-per-Header-Parse-Stil überschreibt das zufällig richtig,
(vermutlich `klasseId`/`teacherId` statt `lsid`); folgt als eigener Schritt, sobald die im Parser kommentiert statt stillschweigend vorausgesetzt. `Fehltage` (0/1, `CountsAsFullDay`) ist
Query-Parameter bekannt sind. Ebenfalls zurückgestellt: Dokumentation/Behördenschriftverkehr als laut Nutzer-Angabe der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil: bleibt 0, bis auch die
eigenständiger, WebUntis-unabhängiger Baustein.
- [x] **"Klassenlehrer"-Feature, Schritt 2 (Fehlzeiten aus fremdem Unterricht, August 2026):** Der
gesuchte Bericht ist `reports.do?name=AbsencePerStudent&klasseOrStudentgroupId=KL<id>&studentId=-1`
(per Netzwerk-Mitschnitt gefunden) — Query-Parameter sind bis auf `name`/`klasseOrStudentgroupId`/
`studentId` identisch mit `ClassregEventPerStudent`. `klasseOrStudentgroupId` ist `"KL"` +
die `getKlassen`-interne Klassen-ID (dasselbe Element-Kürzel-Schema wie `kl`/`te`/`su`/`ro` bei
anderen Berichten) — **kein manuelles Feld nötig**, `WebUntisIntegrationService.ResolveClassIdAsync`
löst sie pro Aufruf über `getSchoolyears`+`getKlassen` und den Klassennamen auf (analog zum
bestehenden Klassen-Auswahldialog). Das CSV-Format ist **nicht** identisch mit `AbsencePerLesson`
(ursprüngliche Vermutung anhand echter Daten widerlegt) — eigenes Spaltenschema, eine Zeile pro
Fehlstunde, neuer Parser `WebUntisClassAbsenceParser`/Modell `UntisClassAbsenceEntry`. Anders als
`AbsencePerLesson` liefert `Status` hier einen Klartext-Wert direkt (im Sample nur `"nicht
entsch."` beobachtet, andere Werte nicht bestätigt), es braucht also keine Status-Heuristik.
Der Header `Text` kommt zweimal vor (eine immer-leere Spalte, dann die eigentliche Tagesnotiz) —
der bestehende Dictionary-per-Header-Parse-Stil überschreibt das zufällig richtig, im Parser
kommentiert statt stillschweigend vorausgesetzt. `Fehltage` (0/1, `CountsAsFullDay`) ist laut
Nutzer-Angabe der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil: bleibt 0, bis auch die
letzte Unterrichtsstunde des Tages als versäumt gilt, erst deren Zeile trägt dann den Wert 1. letzte Unterrichtsstunde des Tages als versäumt gilt, erst deren Zeile trägt dann den Wert 1.
Im Klassenlehrer-Tab (Nutzer-Feedback) **pro Schüler*in und Tag gruppiert** dargestellt statt `ClassAbsenceDaySummaryRow.GroupByStudentAndDay` fasst die Fehlstunden pro Schüler*in/Tag zusammen
einer Zeile pro Fehlstunde — `ClassAbsenceDaySummaryRow.GroupByStudentAndDay` fasst Fehlstunden/ (Fächer/Status/Gründe als distinkte Listen) statt eine Zeile pro Fehlstunde zu zeigen.
-minuten zusammen und sammelt Fächer/Status/Gründe als distinkte Listen. Rein Ansicht, kein - [x] **"Klassenlehrer"-Feature — Neuausrichtung (August 2026):** Die erste Umsetzung hing das
Abgleich/Übernehmen in lokale Anwesenheitsdaten; eigene und fremde Fächer erscheinen bewusst Feature an ein `LearningGroup.IsClassTeacher`-Flag mit Tab in der Gruppendetailansicht.
beide ungefiltert. Nutzer-Feedback: konzeptionell falsch — Klassenlehrer ist man für eine ganze Klasse, unabhängig
**Zurückgestellte Idee:** ein Detail-Drill-down pro Schüler*in (z. B. Heatmap der gefehlten vom eigenen Unterricht (die Checkbox saß z. B. auch an "SOL"/"Klassenrat"), und die Übersicht wird
Einzelstunden, Wochenansicht mit Verspätung/vorzeitigem Verlassen) — noch offen, ob im selben Tab gerade an Tagen gebraucht, an denen man selbst keinen Unterricht mit der Klasse hat.
oder über einen eigenen Tab/Dialog "in die Tiefe" erreichbar; die Rohdaten `LearningGroup.IsClassTeacher` wieder entfernt. Stattdessen:
(`UntisClassAbsenceEntryDto`) sind dafür schon vollständig vorhanden, nur noch nicht in der UI - **Klassenauswahl in den Einstellungen** statt an einer Gruppe:
genutzt. `WebUntisSettingsService.HomeroomClassUntisId`/`HomeroomClassName` (unverschlüsselt, gleiches
Muster wie `TeacherUntisId`), Auswahl im "Untis-Einbettung"-Tab über den bestehenden
`WebUntisClassSelectionViewModel`/`WebUntisClassSelectionDialog` (bisher nur für den
WebUntis-Klassenimport in der Kursansicht genutzt).
- **Eigener Top-Level-Bereich** "Klassenlehrer" in der Seitenleiste (`NavItem.ClassTeacher`,
`ClassTeacherOverviewViewModel`) statt Tab in einer `LearningGroup`-Detailansicht — sichtbar und
ladbar unabhängig davon, ob heute eigener Unterricht mit der Klasse stattfindet. Zeigt bei
fehlender Klassenauswahl einen Hinweis mit Sprung in die Einstellungen.
- **Zwei Ebenen** (Nutzer-Feedback: "auf die Schnelle" statt Rohdaten-Listen als Erstes):
"Übersicht" — kompaktes Roster (`ClassTeacherRosterRow.Build`, Zuordnung primär über `ExternKey`
für die Fehlzeiten, Namens-Fallback für den Klassenbuch-Bericht ohne `ExternKey`) mit
Ampel-Symbol "keine gemeldete Fehlzeit heute laut WebUntis" (unabhängig von eigener
Anwesenheitserfassung) und Badge bei Klassenbucheinträgen anderer Lehrkräfte in den letzten 7
Tagen; "Details" — die bisherigen Rohdaten-Listen (jetzt `ClassTeacherDetailsViewModel`/
`ClassTeacherDetailsView`, aus `ViewModels/Groups`/`Views/Groups` nach
`ViewModels/ClassTeacher`/`Views/ClassTeacher` verschoben, `Initialize(string className)` statt
`Initialize(Guid groupId)`), aus der Übersicht per Klick auf eine Zeile gefiltert auf
eine/n Schüler*in anspringbar (`ClassTeacherDetailsViewModel.StudentFilter`).
**Zurückgestellte Ideen:** Verknüpfung Roster-Zeile ↔ lokaler `Student`/Navigation zum
Schülerdetail; ein Detail-Drill-down pro Schüler*in (Heatmap gefehlter Einzelstunden,
Verspätung/vorzeitiges Verlassen je Woche); eine KI-gestützte Zusammenfassung/Bewertung der
Einträge (pro Schüler*in oder gesamt) mit Namens-Anonymisierung/Synonymen vor dem Versand.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3