diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs
index c2afd80..6bc830f 100644
--- a/LehrerApp.Core/Models/LearningGroup.cs
+++ b/LehrerApp.Core/Models/LearningGroup.cs
@@ -14,6 +14,13 @@ public class LearningGroup
public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; }
///
+ /// Ich bin Klassenlehrer/-in dieser Klasse. Anders als (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.
+ ///
+ public bool IsClassTeacher { get; set; }
+ ///
/// 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
diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
new file mode 100644
index 0000000..4b46583
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
@@ -0,0 +1,65 @@
+using LehrerApp.Desktop.Services;
+using LehrerApp.Desktop.ViewModels.Groups;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class ClassTeacherViewModelsTests
+{
+ [Fact]
+ public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
+ {
+ var entries = new[]
+ {
+ new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
+ "Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
+ new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
+ "Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
+ new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
+ "Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
+ };
+
+ var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
+
+ Assert.Equal(2, rows.Count);
+
+ var max = rows[1];
+ Assert.Equal("Muster Max", max.StudentName);
+ Assert.Equal(2, max.TotalAbsentPeriods);
+ Assert.Equal(90, max.TotalAbsentMinutes);
+ Assert.Equal(["Che", "Kunst"], max.Subjects);
+ Assert.Equal([3, 5], max.PeriodNumbers.Order());
+ Assert.Equal(["nicht entsch."], max.Statuses);
+ Assert.Equal(["Absent"], max.AbsenceReasons);
+ Assert.Equal("Krank", max.Note);
+ Assert.Null(max.ExcuseNote);
+ Assert.True(max.CountsAsFullDay);
+
+ var andere = rows[0];
+ Assert.Equal("Andere Schuelerin", andere.StudentName);
+ Assert.Equal(1, andere.TotalAbsentPeriods);
+ Assert.Equal(20, andere.TotalAbsentMinutes);
+ Assert.Equal(["Deu"], andere.Subjects);
+ Assert.Null(andere.Note);
+ Assert.Equal("Zug verpasst", andere.ExcuseNote);
+ Assert.False(andere.CountsAsFullDay);
+ }
+
+ [Fact]
+ public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
+ {
+ var entries = new[]
+ {
+ new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
+ null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
+ new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
+ null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
+ };
+
+ var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
+
+ Assert.Equal(2, rows.Count);
+ Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
+ Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
index 6847b14..8637a6e 100644
--- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
@@ -1,5 +1,6 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
@@ -29,7 +30,9 @@ 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 GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups),
+ new ClassTeacherTabViewModel(
+ new WebUntisIntegrationService(new HttpClient(), TestSupport.BuildWebUntisSettingsService()), groups));
vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
diff --git a/LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs b/LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs
index f8875a9..5b7b7ba 100644
--- a/LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs
+++ b/LehrerApp.Desktop.Tests/WebUntisIntegrationServiceTests.cs
@@ -30,6 +30,41 @@ public sealed class WebUntisIntegrationServiceTests
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
}
+ [Fact]
+ public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
+ {
+ var csv = Encoding.UTF8.GetBytes(
+ "Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
+ "Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
+ "Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
+ var handler = new QueueHandler(
+ Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
+ Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
+ Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
+ Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
+ Json("{\"data\":{\"finished\":true,\"error\":false," +
+ "\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
+ new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
+ Json("{\"result\":{}}")); // logout (Dispose)
+ var settings = TestSupport.BuildWebUntisSettingsService();
+ await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
+
+ await service.ConnectAsync(new WebUntisCredentials(
+ "bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
+ var entries = await service.GetClassAbsencesAsync("10c",
+ new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
+
+ var entry = Assert.Single(entries);
+ Assert.Equal("Muster Max", entry.StudentName);
+ Assert.Equal("nicht entsch.", entry.Status);
+ // Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
+ Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
+ Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
+ Assert.Single(handler.Requests, request =>
+ request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
+ request.Uri.Contains("klasseOrStudentgroupId=KL874"));
+ }
+
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index 7cda975..d9db3a1 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -308,6 +308,7 @@ public static class AppBootstrapper
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
diff --git a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
index ae6b444..905b23c 100644
--- a/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
+++ b/LehrerApp.Desktop/Services/WebUntisIntegrationService.cs
@@ -32,6 +32,12 @@ public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int Abs
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
+public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
+ string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
+public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date,
+ int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason,
+ string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber,
+ string? Status, bool CountsAsFullDay);
/// Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.
@@ -135,6 +141,65 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
.ToList();
}, token);
+ // Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
+ // dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
+ // die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
+ // sobald eine Lehrkraft mehrere Klassen unterrichtet).
+ public Task> GetForeignClassRegisterEventsAsync(
+ string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
+ {
+ var ownUsername = settings.GetApiCredentials()?.Username;
+ var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
+ return (IReadOnlyList)entries
+ .Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
+ // Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
+ // ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
+ // (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
+ && !(ownUsername is not null
+ && string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
+ .Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
+ x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
+ .ToList();
+ }, token);
+
+ // Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
+ // für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
+ // eines Namens, siehe ResolveClassIdAsync.
+ public Task> GetClassAbsencesAsync(string className,
+ DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
+ {
+ var classId = await ResolveClassIdAsync(client, className, token);
+ var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
+ return (IReadOnlyList)entries.Select(x => new UntisClassAbsenceEntryDto(
+ x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
+ x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
+ x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
+ .ToList();
+ }, token);
+
+ // "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
+ // (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
+ // aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
+ // Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
+ private static async Task ResolveClassIdAsync(WebUntisClient client, string className,
+ CancellationToken token)
+ {
+ var years = await client.GetSchoolYearsAsync(token);
+ var today = Date(DateOnly.FromDateTime(DateTime.Today));
+ var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
+ ?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
+ ?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
+ var classes = await client.GetClassesAsync(year.UntisId, token);
+ var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
+ return matches.Count switch
+ {
+ 1 => $"KL{matches[0].UntisId}",
+ 0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
+ _ => throw new WebUntisIntegrationException(
+ $"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
+ };
+ }
+
private async Task ExecuteAsync(Func> operation, CancellationToken token)
{
try { return await operation(await GetClientAsync(token)); }
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ClassTeacherViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ClassTeacherViewModels.cs
new file mode 100644
index 0000000..0434592
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Groups/ClassTeacherViewModels.cs
@@ -0,0 +1,133 @@
+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;
+
+/// 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
+/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.
+public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
+ string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
+{
+ public string DateLabel => Date.ToString("dd.MM.yyyy");
+}
+
+/// Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
+/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
+/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
+/// Rohdaten). Die Rohzeilen () bleiben trotzdem erreichbar,
+/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).
+public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
+ int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList Subjects,
+ IReadOnlyList PeriodNumbers, IReadOnlyList Statuses, IReadOnlyList AbsenceReasons,
+ string? Note, string? ExcuseNote, bool CountsAsFullDay)
+{
+ public string DateLabel => Date.ToString("dd.MM.yyyy");
+ public string SubjectsLabel => string.Join(", ", Subjects);
+ public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
+ public string StatusLabel => string.Join(", ", Statuses);
+ public string ReasonLabel => string.Join("; ", AbsenceReasons);
+
+ public static IReadOnlyList GroupByStudentAndDay(
+ IEnumerable entries) => entries
+ .Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
+ .Where(x => x.Date is not null)
+ .GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
+ .Select(g => new ClassAbsenceDaySummaryRow(
+ g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
+ g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
+ Distinct(g.Select(x => x.Entry.Subject)),
+ g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
+ Distinct(g.Select(x => x.Entry.Status)),
+ Distinct(g.Select(x => x.Entry.AbsenceReason)),
+ g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
+ g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
+ g.Any(x => x.Entry.CountsAsFullDay)))
+ .OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
+ .ToList();
+
+ private static List Distinct(IEnumerable values) =>
+ values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
+
+ private static bool TryDate(int value, out DateOnly date) =>
+ DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
+}
+
+///
+/// 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
+/// ), sowie die
+/// Fehlzeiten der Klasse über alle Fächer ().
+/// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Nur für Gruppen mit
+/// sichtbar (siehe
+/// GroupDetailViewModel/GroupDetailView).
+///
+public partial class ClassTeacherTabViewModel : ObservableObject
+{
+ private readonly WebUntisIntegrationService _untis;
+ private readonly IGroupRepository _groups;
+ private string _className = "";
+
+ public ObservableCollection Entries { get; } = [];
+ public ObservableCollection AbsenceEntries { get; } = [];
+
+ [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-7);
+ [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
+ [ObservableProperty] private string _status = "Zeitraum wählen und laden.";
+ [ObservableProperty] private bool _busy;
+
+ public ClassTeacherTabViewModel(WebUntisIntegrationService untis, IGroupRepository groups)
+ {
+ _untis = untis; _groups = groups;
+ }
+
+ public void Initialize(Guid groupId)
+ {
+ _className = _groups.GetById(groupId)?.Name ?? "";
+ Entries.Clear();
+ AbsenceEntries.Clear();
+ Status = "Zeitraum wählen und laden.";
+ }
+
+ [RelayCommand]
+ private async Task Load()
+ {
+ 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; }
+
+ Busy = true; Entries.Clear(); AbsenceEntries.Clear();
+ try
+ {
+ var classRegisterTask = _untis.GetForeignClassRegisterEventsAsync(_className, start, end);
+ var absencesTask = _untis.GetClassAbsencesAsync(_className, start, end);
+ await Task.WhenAll(classRegisterTask, absencesTask);
+
+ var ordered = classRegisterTask.Result
+ .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);
+ foreach (var (entry, date) in ordered)
+ {
+ Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
+ entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
+ }
+
+ foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result))
+ AbsenceEntries.Add(row);
+
+ Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
+ $"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
+ }
+ catch (WebUntisIntegrationException ex) { Status = ex.Message; }
+ finally { Busy = false; }
+ }
+
+ private static bool TryDate(int value, out DateOnly date) =>
+ DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
index 265dad6..3b2c599 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
@@ -251,6 +251,7 @@ 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; } = "";
@@ -258,6 +259,7 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnGroupChanged(LearningGroup? value)
{
OnPropertyChanged(nameof(IsDifferentiated));
+ OnPropertyChanged(nameof(IsClassTeacher));
OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands();
@@ -279,6 +281,7 @@ public partial class GroupDetailViewModel : ObservableObject
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; }
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
+ public ClassTeacherTabViewModel ClassTeacherTab { get; }
public Func>? OnAddStudent { get; set; }
public Func>? OnWithdrawStudent { get; set; }
public Func>? OnAddExam { get; set; }
@@ -295,7 +298,8 @@ public partial class GroupDetailViewModel : ObservableObject
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
- SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
+ SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab,
+ ClassTeacherTabViewModel classTeacherTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
@@ -306,6 +310,7 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
GroupDocumentationTab = groupDocumentationTab;
+ ClassTeacherTab = classTeacherTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
@@ -335,6 +340,7 @@ public partial class GroupDetailViewModel : ObservableObject
CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
GroupDocumentationTab.Initialize(Group.Id);
+ ClassTeacherTab.Initialize(Group.Id);
}
private void ReloadExams()
@@ -877,6 +883,7 @@ 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 = "";
@@ -917,6 +924,7 @@ 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));
@@ -964,6 +972,7 @@ 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);
diff --git a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
index fd63de9..d4070c7 100644
--- a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
@@ -71,6 +71,9 @@
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/ClassTeacherTabView.axaml b/LehrerApp.Desktop/Views/Groups/ClassTeacherTabView.axaml
new file mode 100644
index 0000000..10245ab
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/ClassTeacherTabView.axaml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/ClassTeacherTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/ClassTeacherTabView.axaml.cs
new file mode 100644
index 0000000..dfbf55c
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/ClassTeacherTabView.axaml.cs
@@ -0,0 +1,8 @@
+using Avalonia.Controls;
+
+namespace LehrerApp.Desktop.Views.Groups;
+
+public partial class ClassTeacherTabView : UserControl
+{
+ public ClassTeacherTabView() => InitializeComponent();
+}
diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
index 085241a..dd9b41c 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
@@ -205,6 +205,11 @@
+
+
+
+
+
diff --git a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
index 63617e1..ce81bbd 100644
--- a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
+++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs
@@ -244,6 +244,53 @@ public sealed class WebUntisClientTests
await client.DisposeAsync();
}
+ [Fact]
+ public async Task GetClassAbsencesAsync_BautDenBerichtsAbrufAufUndTypisiertDasErgebnis()
+ {
+ // Kopfzeile "Text" kommt zweimal vor (Spalte 3 leer, Spalte 12 die eigentliche Tagesnotiz) -
+ // deckt ab, dass die spätere Spalte beim Parsen die frühere überschreibt (siehe Parser-Kommentar).
+ var csv = Encoding.UTF8.GetBytes(
+ "Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
+ "Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
+ "Muster Max\t12345\t\t6a\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
+ var handler = new QueueHandler(
+ Json("{\"result\":{\"sessionId\":\"s\",\"personId\":89}}"),
+ Json("{\"data\":{\"finished\":true,\"error\":false," +
+ "\"reportParams\":\"get=rpt3.tmp&name=AbsencePerStudent&format=csv\"}}"),
+ new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
+ Json("{\"result\":{}}"));
+ var client = CreateClient(handler);
+
+ var entries = await client.GetClassAbsencesAsync("KL874", 20260824, 20260828, CancellationToken.None);
+
+ var entry = Assert.Single(entries);
+ Assert.Equal("Muster Max", entry.StudentName);
+ Assert.Equal(12345, entry.ExternKey);
+ Assert.Equal("6a", entry.ClassName);
+ Assert.Equal(20260824, entry.Date);
+ Assert.Equal(1, entry.AbsentPeriods);
+ Assert.Equal(45, entry.AbsentMinutes);
+ Assert.Equal("HED", entry.TeacherUsernames);
+ Assert.Equal("Che", entry.Subject);
+ Assert.Equal("Absent", entry.AbsenceReason);
+ Assert.Equal("Krank gemeldet", entry.Note);
+ Assert.Equal(555, entry.EntryId);
+ Assert.Equal("24.08.26", entry.HandledOn);
+ Assert.True(entry.Counts);
+ Assert.Null(entry.ExcuseNote);
+ Assert.Equal(3, entry.PeriodNumber);
+ Assert.Equal("nicht entsch.", entry.Status);
+ Assert.True(entry.CountsAsFullDay);
+ Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri);
+ Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri);
+ Assert.Contains("studentId=-1", handler.Requests[1].Uri);
+ Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
+ Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
+ Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri);
+
+ await client.DisposeAsync();
+ }
+
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
new HttpClient(handler),
new WebUntisOptions
diff --git a/LehrerApp.WebUntis/WebUntisClassAbsenceParser.cs b/LehrerApp.WebUntis/WebUntisClassAbsenceParser.cs
new file mode 100644
index 0000000..c30b283
--- /dev/null
+++ b/LehrerApp.WebUntis/WebUntisClassAbsenceParser.cs
@@ -0,0 +1,79 @@
+using System.Globalization;
+
+namespace LehrerApp.WebUntis;
+
+public static class WebUntisClassAbsenceParser
+{
+ public static IReadOnlyList Parse(string content)
+ {
+ var rows = TabSeparatedTextReader.ParseRows(content, '\t');
+ if (rows.Count == 0) return [];
+
+ var headers = rows[0]
+ .Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
+ .ToArray();
+ var result = new List();
+
+ foreach (var row in rows.Skip(1))
+ {
+ if (row.All(string.IsNullOrWhiteSpace)) continue;
+
+ // Der Header "Text" kommt zweimal vor (Spalte 3, in echten Daten immer leer; die
+ // eigentliche freitextliche Tagesnotiz steht in der späteren Spalte). Der spätere Index
+ // überschreibt beim Befüllen den früheren, was hier zufällig den gewünschten Wert übrig
+ // lässt - bewusst kein Verlass darauf, dass Spalte 3 auch künftig immer leer bleibt.
+ var values = new Dictionary(StringComparer.Ordinal);
+ for (var index = 0; index < headers.Length; index++)
+ values[headers[index]] = index < row.Count ? row[index].Trim() : null;
+
+ result.Add(new UntisClassAbsenceEntry(
+ Get(values, "Schüler*innen")?.Trim() ?? "",
+ OptionalInt(Get(values, "Externe Id")),
+ Get(values, "Klasse")?.Trim() ?? "",
+ GermanShortDate(Get(values, "Datum")) ?? throw new InvalidDataException(
+ "Ungültiges Datum in Spalte \"Datum\"."),
+ RequiredInt(Get(values, "Fehlstd."), "Fehlstd."),
+ RequiredInt(Get(values, "Fehlmin."), "Fehlmin."),
+ Optional(Get(values, "Lehrkraft")),
+ Optional(Get(values, "Fach")),
+ Optional(Get(values, "Abwesenheitsgrund")),
+ Optional(Get(values, "Text")),
+ OptionalInt(Get(values, "ENr")),
+ Optional(Get(values, "Erledigt")),
+ ParseBoolean(Get(values, "Abwesenheit zählt")),
+ Optional(Get(values, "Entschuldigungstext")),
+ OptionalInt(Get(values, "Stundennr.")),
+ Optional(Get(values, "Status")),
+ Get(values, "Fehltage")?.Trim() == "1"));
+ }
+
+ return result;
+ }
+
+ private static bool ParseBoolean(string? value) =>
+ string.Equals(value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
+
+ private static string? Get(IReadOnlyDictionary values, string key) =>
+ values.TryGetValue(key, out var value) ? value : null;
+
+ private static string? Optional(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private static int? OptionalInt(string? value) =>
+ int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : null;
+
+ private static int RequiredInt(string? value, string field) =>
+ int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
+
+ private static int? GermanShortDate(string? value)
+ {
+ if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yy", CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var date))
+ return null;
+ return date.Year * 10_000 + date.Month * 100 + date.Day;
+ }
+}
diff --git a/LehrerApp.WebUntis/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs
index 667605a..ccf2a1e 100644
--- a/LehrerApp.WebUntis/WebUntisClient.cs
+++ b/LehrerApp.WebUntis/WebUntisClient.cs
@@ -175,6 +175,35 @@ public sealed class WebUntisClient : IAsyncDisposable
return WebUntisClassRegisterEventReportParser.Parse(reportText);
}, cancellationToken);
+ // Undokumentierter Bericht "AbsencePerStudent" (Unterricht -> Berichte -> Fehlzeiten der ganzen
+ // Klasse), Gegenstück zu GetLessonAbsencesAsync für das "Klassenlehrer"-Feature: deckt alle
+ // Fächer der Klasse ab, nicht nur den eigenen Unterricht. Adressiert die Klasse über
+ // klasseOrStudentgroupId = "KL" + getKlassen-ID (Auflösung siehe
+ // WebUntisIntegrationService.ResolveClassIdAsync) statt einer lsid. Query-Parameter 1:1 aus
+ // einem echten Browser-Request übernommen (identisch mit GetClassRegisterEventsReportAsync bis
+ // auf name/klasseOrStudentgroupId/studentId; _csrf/selectedDateRange dort schon als nicht nötig
+ // bekannt).
+ public Task> GetClassAbsencesAsync(string klasseOrStudentgroupId,
+ int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
+ {
+ var query = "name=AbsencePerStudent&format=csv" +
+ $"&klasseOrStudentgroupId={Uri.EscapeDataString(klasseOrStudentgroupId)}&studentId=-1" +
+ "&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
+ "&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
+ "&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
+ "&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
+ "&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
+ "&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
+ "&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
+ "&_withoutPageBreaks=on&withStudentAbsences=true&_withStudentAbsences=on" +
+ "&examinationTypeId=-1&teachingMethodId=-1&examTypeId=-1&_usingMarkNames=on" +
+ $"&rpt_sd={startDate}&rpt_ed={endDate}&rpt_syid=-1&rpt_drdtype=CUSTOM";
+ var reportData = await RequestReportAsync(sessionId, query, cancellationToken)
+ ?? await PollReportAsync(sessionId, cancellationToken);
+ var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
+ return WebUntisClassAbsenceParser.Parse(reportText);
+ }, cancellationToken);
+
public Task> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
diff --git a/LehrerApp.WebUntis/WebUntisModels.cs b/LehrerApp.WebUntis/WebUntisModels.cs
index 4582b3c..7193e8e 100644
--- a/LehrerApp.WebUntis/WebUntisModels.cs
+++ b/LehrerApp.WebUntis/WebUntisModels.cs
@@ -147,6 +147,33 @@ public sealed record UntisLessonAbsence(
string? HandledOn,
bool Counts);
+// Undokumentierter Bericht "AbsencePerStudent" (Fehlzeiten der ganzen Klasse über alle Fächer,
+// nicht nur den eigenen Unterricht wie AbsencePerLesson) - eigenes Spaltenschema, eine Zeile pro
+// Fehlstunde. Anders als bei AbsencePerLesson liefert "Status" hier einen Klartext-Wert direkt
+// (im Sample nur "nicht entsch." beobachtet, andere Werte nicht bestätigt - Rohwert durchreichen).
+// "Fehltage" ist der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil (Nutzer-Angabe, nicht
+// durch WebUntis-Dokumentation bestätigt): bleibt 0, bis auch die letzte Unterrichtsstunde des
+// Tages als versäumt gilt - erst dann springt er auf 1. Pro Schüler*in/Tag trägt deshalb genau die
+// Zeile der letzten Stunde des Tages den Wert 1, alle vorherigen Stunden dieses Tages bleiben 0.
+public sealed record UntisClassAbsenceEntry(
+ string StudentName,
+ int? ExternKey,
+ string ClassName,
+ int Date,
+ int AbsentPeriods,
+ int AbsentMinutes,
+ string? TeacherUsernames,
+ string? Subject,
+ string? AbsenceReason,
+ string? Note,
+ int? EntryId,
+ string? HandledOn,
+ bool Counts,
+ string? ExcuseNote,
+ int? PeriodNumber,
+ string? Status,
+ bool CountsAsFullDay);
+
// "-alle-"-Bericht ohne externe Schülerkennung - anders als bei AbsencePerLesson bleibt für den
// Abgleich mit lokalen Schülern nur der Name (siehe Namens-Fallback im Desktop-Abgleich).
public sealed record UntisClassRegisterEventReportEntry(
diff --git a/TODO.md b/TODO.md
index 0128208..612a6cc 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1277,11 +1277,53 @@ 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.
-- **Idee, noch nicht geplant ("Klassenlehrer"-Feature):** 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. Eine Klassenlehrer-Übersicht, die stattdessen genau diese fremden Einträge zur
- eigenen Klasse zeigt, wäre ein eigenständiges, größeres Feature und ist bewusst zurückgestellt.
+- [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&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.
+ 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.
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3