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:
@@ -14,13 +14,6 @@ public class LearningGroup
|
||||
public bool IsOwnClass { get; set; }
|
||||
public bool IsDifferentiated { get; set; }
|
||||
/// <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
|
||||
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
|
||||
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
|
||||
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, 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.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
@@ -30,9 +29,7 @@ public sealed class GroupDetailViewModelTests
|
||||
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
|
||||
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
|
||||
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
|
||||
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups),
|
||||
new ClassTeacherTabViewModel(
|
||||
new WebUntisIntegrationService(new HttpClient(), TestSupport.BuildWebUntisSettingsService()), groups));
|
||||
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
|
||||
|
||||
vm.LoadGroup(group.Id);
|
||||
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+27
-16
@@ -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()
|
||||
|
||||
+17
-11
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- [x] **"Klassenlehrer"-Feature, Schritt 1 (Klassenbuch fremder Einträge, August 2026):** Der
|
||||
"-alle-"-Bericht enthält für die eigene Klasse auch Einträge, die andere Lehrkräfte in deren
|
||||
Fächern angelegt haben (`Benutzer` ≠ eigener Login) — für den reinen Dokumentations-Abgleich
|
||||
(eigene Einträge synchron halten) werden die herausgefiltert. Neues Flag
|
||||
`LearningGroup.IsClassTeacher` ("Klassenlehrer/-in dieser Klasse", Stammdaten-Checkbox, keine
|
||||
Migration nötig) blendet einen neuen Tab "Klassenlehrer" in der Gruppendetailansicht ein —
|
||||
bewusst ein eigenes Flag statt `IsOwnClass` (das ist rein die Kalenderfarb-Markierung und
|
||||
mehrfach setzbar für Klasse + zugehörige Kurse, würde den Tab sonst versehentlich auch bei
|
||||
Kursen zeigen). `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync` ist das
|
||||
Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht, aber Einträge
|
||||
anderer Lehrkräfte statt der eigenen, zusätzlich auf die übergebene Klasse eingeschränkt (kein
|
||||
neuer WebUntis-Bericht nötig). `ClassTeacherTabViewModel`/`ClassTeacherTabView` zeigen die
|
||||
Liste rein lesend an (Datum, Schüler*in, Fach/Lehrkraft, Kategorie, Text) — kein
|
||||
"Übernehmen"-Command wie beim Dokumentations-Abgleich, da es fremde Einträge sind, keine
|
||||
eigenen zum lokalen Synchronisieren.
|
||||
**Nicht Teil dieses Schritts:** Fehlzeiten der Klasse aus fremdem Unterricht — dafür fehlt noch
|
||||
ein per Netzwerk-Mitschnitt zu ermittelnder klassenbezogener Fehlzeiten-Bericht ohne `lessonId`
|
||||
(vermutlich `klasseId`/`teacherId` statt `lsid`); folgt als eigener Schritt, sobald die
|
||||
Query-Parameter bekannt sind. Ebenfalls zurückgestellt: Dokumentation/Behördenschriftverkehr als
|
||||
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
|
||||
- [x] **"Klassenlehrer"-Feature — Datenschicht (August 2026):** Zwei WebUntis-Berichte liefern, was
|
||||
für einen Klassenlehrer-Überblick fehlte. `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync`
|
||||
ist das Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht
|
||||
(`ClassregEventPerStudent`), aber Einträge anderer Lehrkräfte (`Benutzer` ≠ eigener Login) statt
|
||||
der eigenen, auf eine Klasse eingeschränkt — kein neuer Bericht nötig.
|
||||
`GetClassAbsencesAsync`/`reports.do?name=AbsencePerStudent&klasseOrStudentgroupId=KL<id>&studentId=-1`
|
||||
(per Netzwerk-Mitschnitt gefunden) liefert Fehlzeiten der ganzen Klasse über alle Fächer, nicht nur
|
||||
eigenen Unterricht. `klasseOrStudentgroupId` ist `"KL"` + die `getKlassen`-interne Klassen-ID
|
||||
(dasselbe Element-Kürzel-Schema wie `kl`/`te`/`su`/`ro` bei anderen Berichten) —
|
||||
`WebUntisIntegrationService.ResolveClassIdAsync` löst sie pro Aufruf über
|
||||
`getSchoolyears`+`getKlassen` und den Klassennamen auf. Das CSV-Format ist **nicht** identisch mit
|
||||
`AbsencePerLesson` (ursprüngliche Vermutung anhand echter Daten widerlegt) — eigenes Spaltenschema,
|
||||
eine Zeile pro Fehlstunde, eigener 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 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.
|
||||
Im Klassenlehrer-Tab (Nutzer-Feedback) **pro Schüler*in und Tag gruppiert** dargestellt statt
|
||||
einer Zeile pro Fehlstunde — `ClassAbsenceDaySummaryRow.GroupByStudentAndDay` fasst Fehlstunden/
|
||||
-minuten zusammen und sammelt Fächer/Status/Gründe als distinkte Listen. Rein Ansicht, kein
|
||||
Abgleich/Übernehmen in lokale Anwesenheitsdaten; eigene und fremde Fächer erscheinen bewusst
|
||||
beide ungefiltert.
|
||||
**Zurückgestellte Idee:** ein Detail-Drill-down pro Schüler*in (z. B. Heatmap der gefehlten
|
||||
Einzelstunden, Wochenansicht mit Verspätung/vorzeitigem Verlassen) — noch offen, ob im selben Tab
|
||||
oder über einen eigenen Tab/Dialog "in die Tiefe" erreichbar; die Rohdaten
|
||||
(`UntisClassAbsenceEntryDto`) sind dafür schon vollständig vorhanden, nur noch nicht in der UI
|
||||
genutzt.
|
||||
`ClassAbsenceDaySummaryRow.GroupByStudentAndDay` fasst die Fehlstunden pro Schüler*in/Tag zusammen
|
||||
(Fächer/Status/Gründe als distinkte Listen) statt eine Zeile pro Fehlstunde zu zeigen.
|
||||
- [x] **"Klassenlehrer"-Feature — Neuausrichtung (August 2026):** Die erste Umsetzung hing das
|
||||
Feature an ein `LearningGroup.IsClassTeacher`-Flag mit Tab in der Gruppendetailansicht.
|
||||
Nutzer-Feedback: konzeptionell falsch — Klassenlehrer ist man für eine ganze Klasse, unabhängig
|
||||
vom eigenen Unterricht (die Checkbox saß z. B. auch an "SOL"/"Klassenrat"), und die Übersicht wird
|
||||
gerade an Tagen gebraucht, an denen man selbst keinen Unterricht mit der Klasse hat.
|
||||
`LearningGroup.IsClassTeacher` wieder entfernt. Stattdessen:
|
||||
- **Klassenauswahl in den Einstellungen** statt an einer Gruppe:
|
||||
`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
|
||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||
|
||||
Reference in New Issue
Block a user