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
+3 -1
View File
@@ -5,6 +5,7 @@ using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
@@ -297,6 +298,8 @@ public static class AppBootstrapper
services.AddSingleton<TimeTrackingViewModel>();
services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
@@ -308,7 +311,6 @@ public static class AppBootstrapper
services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<ClassTeacherTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>();
@@ -11,6 +11,8 @@ internal class WebUntisSettingsConfig
public string LastSyncStatus { get; set; } = "";
public string? EncryptedApiCredentials { 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);
@@ -36,6 +38,8 @@ public class WebUntisSettingsService
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
public int? TeacherUntisId => _config.TeacherUntisId;
public int? HomeroomClassUntisId => _config.HomeroomClassUntisId;
public string? HomeroomClassName => _config.HomeroomClassName;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
@@ -83,6 +87,8 @@ public class WebUntisSettingsService
{
_config.EncryptedApiCredentials = null;
_config.TeacherUntisId = null;
_config.HomeroomClassUntisId = null;
_config.HomeroomClassName = null;
Save();
}
@@ -92,6 +98,16 @@ public class WebUntisSettingsService
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)
{
_config.LastSyncAt = at;
@@ -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()
@@ -1,10 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.ClassTeacherTabView"
x:DataType="vm:ClassTeacherTabViewModel">
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherDetailsView"
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"
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}"/>
</StackPanel>
<TextBlock Grid.Row="2" Text="Klassenbucheinträge anderer Lehrkräfte" FontWeight="SemiBold" FontSize="13" Margin="0,4,0,0"/>
<Grid Grid.Row="3" ColumnDefinitions="70,1*,1.1*,0.8*,1*,1.3*" ColumnSpacing="8" Margin="4,0">
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
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="1" Text="Schüler*in" 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="5" Text="Text" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="4">
<ScrollViewer Grid.Row="5">
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherClassRegisterRow">
@@ -44,9 +50,9 @@
</ItemsControl>
</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"/>
<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="1" Text="Schüler*in" 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="7" Text="Grund / Notiz" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="7">
<ScrollViewer Grid.Row="8">
<ItemsControl ItemsSource="{Binding AbsenceEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
@@ -79,6 +85,6 @@
</ItemsControl>
</ScrollViewer>
<TextBlock Grid.Row="8" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Grid.Row="9" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
</Grid>
</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}"
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}"
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}"/>
</ContentPage>
<!-- Tab: Klassenlehrer, nur sichtbar für Gruppen mit IsClassTeacher -->
<ContentPage Header="Klassenlehrer" IsVisible="{Binding IsClassTeacher}">
<views:ClassTeacherTabView DataContext="{Binding ClassTeacherTab}"/>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
+15
View File
@@ -13,6 +13,8 @@
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.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"
x:Class="LehrerApp.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
@@ -58,6 +60,9 @@
<DataTemplate DataType="vmw:WorkloadViewModel">
<vw:WorkloadView/>
</DataTemplate>
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
<vct:ClassTeacherOverviewView/>
</DataTemplate>
<DataTemplate DataType="vm:PlaceholderViewModel">
<views:PlaceholderView/>
</DataTemplate>
@@ -209,6 +214,16 @@
<TextBlock Classes="navlabel" Text="Arbeitszeit"/>
</StackPanel>
</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"
HorizontalContentAlignment="Left"
CornerRadius="6"
@@ -1000,6 +1000,19 @@
IsVisible="{Binding UntisApiIsConfigured}" IsEnabled="{Binding !UntisApiBusy}"/>
</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"/>
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
@@ -4,8 +4,10 @@ using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Planning;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
@@ -31,6 +33,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
vm.OnPickHomeroomClass = PickHomeroomClassAsync;
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
}
}
@@ -51,6 +54,20 @@ public partial class SettingsView : UserControl
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)
{
var topLevel = TopLevel.GetTopLevel(this);