WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)
Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über "KL"+getKlassen-ID, automatisch aufgelöst - 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,6 +14,13 @@ public class LearningGroup
|
|||||||
public bool IsOwnClass { get; set; }
|
public bool IsOwnClass { get; set; }
|
||||||
public bool IsDifferentiated { get; set; }
|
public bool IsDifferentiated { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Ich bin Klassenlehrer/-in dieser Klasse. Anders als <see cref="IsOwnClass"/> (rein
|
||||||
|
/// kalenderfarbliche Markierung, mehrfach setzbar für Klasse + zugehörige Kurse) schaltet
|
||||||
|
/// dieses Flag Zugriff auf Daten anderer Lehrkräfte zu dieser Klasse frei (Klassenlehrer-Tab)
|
||||||
|
/// und sollte deshalb nur an genau einer Klasse-Gruppe gesetzt werden.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsClassTeacher { get; set; }
|
||||||
|
/// <summary>
|
||||||
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
|
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
|
||||||
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
|
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
|
||||||
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
|
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -29,7 +30,9 @@ public sealed class GroupDetailViewModelTests
|
|||||||
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
|
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
|
||||||
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
|
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
|
||||||
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
|
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
|
||||||
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
|
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups),
|
||||||
|
new ClassTeacherTabViewModel(
|
||||||
|
new WebUntisIntegrationService(new HttpClient(), TestSupport.BuildWebUntisSettingsService()), groups));
|
||||||
|
|
||||||
vm.LoadGroup(group.Id);
|
vm.LoadGroup(group.Id);
|
||||||
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
||||||
|
|||||||
@@ -30,6 +30,41 @@ public sealed class WebUntisIntegrationServiceTests
|
|||||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
|
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)
|
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ public static class AppBootstrapper
|
|||||||
services.AddTransient<CompetencyOverviewTabViewModel>();
|
services.AddTransient<CompetencyOverviewTabViewModel>();
|
||||||
services.AddTransient<SeatingPlanTabViewModel>();
|
services.AddTransient<SeatingPlanTabViewModel>();
|
||||||
services.AddTransient<GroupDocumentationTabViewModel>();
|
services.AddTransient<GroupDocumentationTabViewModel>();
|
||||||
|
services.AddTransient<ClassTeacherTabViewModel>();
|
||||||
services.AddTransient<AddGroupDialogViewModel>();
|
services.AddTransient<AddGroupDialogViewModel>();
|
||||||
services.AddTransient<TrashViewModel>();
|
services.AddTransient<TrashViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int Abs
|
|||||||
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
|
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
|
||||||
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
|
||||||
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
|
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);
|
||||||
|
|
||||||
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
|
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
|
||||||
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
|
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
|
||||||
@@ -135,6 +141,65 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
|
|||||||
.ToList();
|
.ToList();
|
||||||
}, token);
|
}, 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<IReadOnlyList<UntisForeignClassRegisterEventDto>> 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<UntisForeignClassRegisterEventDto>)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<IReadOnlyList<UntisClassAbsenceEntryDto>> 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<UntisClassAbsenceEntryDto>)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<string> 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<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
|
||||||
{
|
{
|
||||||
try { return await operation(await GetClientAsync(token)); }
|
try { return await operation(await GetClientAsync(token)); }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.</summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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 (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
|
||||||
|
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
|
||||||
|
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
|
||||||
|
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
|
||||||
|
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> 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<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
|
||||||
|
IEnumerable<UntisClassAbsenceEntryDto> 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<string> Distinct(IEnumerable<string?> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// <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).
|
||||||
|
/// </summary>
|
||||||
|
public partial class ClassTeacherTabViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly WebUntisIntegrationService _untis;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
|
private string _className = "";
|
||||||
|
|
||||||
|
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
|
||||||
|
public ObservableCollection<ClassAbsenceDaySummaryRow> 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);
|
||||||
|
}
|
||||||
@@ -251,6 +251,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
|
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
|
||||||
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
|
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
|
||||||
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
|
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
|
||||||
|
public bool IsClassTeacher => Group?.IsClassTeacher ?? false;
|
||||||
public bool IsReadOnly => Group is { IsActive: false };
|
public bool IsReadOnly => Group is { IsActive: false };
|
||||||
public bool IsEditable => Group is { IsActive: true };
|
public bool IsEditable => Group is { IsActive: true };
|
||||||
public string SubjectName { get; private set; } = "";
|
public string SubjectName { get; private set; } = "";
|
||||||
@@ -258,6 +259,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
partial void OnGroupChanged(LearningGroup? value)
|
partial void OnGroupChanged(LearningGroup? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsDifferentiated));
|
OnPropertyChanged(nameof(IsDifferentiated));
|
||||||
|
OnPropertyChanged(nameof(IsClassTeacher));
|
||||||
OnPropertyChanged(nameof(IsReadOnly));
|
OnPropertyChanged(nameof(IsReadOnly));
|
||||||
OnPropertyChanged(nameof(IsEditable));
|
OnPropertyChanged(nameof(IsEditable));
|
||||||
NotifyWriteCommands();
|
NotifyWriteCommands();
|
||||||
@@ -279,6 +281,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
|
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
|
||||||
public SeatingPlanTabViewModel SeatingPlanTab { get; }
|
public SeatingPlanTabViewModel SeatingPlanTab { get; }
|
||||||
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
|
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
|
||||||
|
public ClassTeacherTabViewModel ClassTeacherTab { get; }
|
||||||
public Func<Task<bool>>? OnAddStudent { get; set; }
|
public Func<Task<bool>>? OnAddStudent { get; set; }
|
||||||
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
|
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
|
||||||
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
|
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
|
||||||
@@ -295,7 +298,8 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
|
GroupOverviewViewModel overviewTab, ParticipationTabViewModel participationTab,
|
||||||
GradeOverviewTabViewModel gradeOverviewTab,
|
GradeOverviewTabViewModel gradeOverviewTab,
|
||||||
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
|
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
|
||||||
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
|
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab,
|
||||||
|
ClassTeacherTabViewModel classTeacherTab)
|
||||||
{
|
{
|
||||||
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
|
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
|
||||||
_exams = exams; _grades = grades; _tasks = tasks;
|
_exams = exams; _grades = grades; _tasks = tasks;
|
||||||
@@ -306,6 +310,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
CompetencyOverviewTab = competencyOverviewTab;
|
CompetencyOverviewTab = competencyOverviewTab;
|
||||||
SeatingPlanTab = seatingPlanTab;
|
SeatingPlanTab = seatingPlanTab;
|
||||||
GroupDocumentationTab = groupDocumentationTab;
|
GroupDocumentationTab = groupDocumentationTab;
|
||||||
|
ClassTeacherTab = classTeacherTab;
|
||||||
SeatingPlanTab.OnAssessmentChanged = () =>
|
SeatingPlanTab.OnAssessmentChanged = () =>
|
||||||
{
|
{
|
||||||
ParticipationTab.LoadSessions();
|
ParticipationTab.LoadSessions();
|
||||||
@@ -335,6 +340,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
CompetencyOverviewTab.Initialize(Group);
|
CompetencyOverviewTab.Initialize(Group);
|
||||||
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
|
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
|
||||||
GroupDocumentationTab.Initialize(Group.Id);
|
GroupDocumentationTab.Initialize(Group.Id);
|
||||||
|
ClassTeacherTab.Initialize(Group.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReloadExams()
|
private void ReloadExams()
|
||||||
@@ -877,6 +883,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int? _hoursPerWeek;
|
[ObservableProperty] private int? _hoursPerWeek;
|
||||||
[ObservableProperty] private bool _isOwnClass;
|
[ObservableProperty] private bool _isOwnClass;
|
||||||
[ObservableProperty] private bool _isDifferentiated;
|
[ObservableProperty] private bool _isDifferentiated;
|
||||||
|
[ObservableProperty] private bool _isClassTeacher;
|
||||||
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||||
[ObservableProperty] private int? _webUntisLessonId;
|
[ObservableProperty] private int? _webUntisLessonId;
|
||||||
[ObservableProperty] private string _nameError = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
@@ -917,6 +924,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
HoursPerWeek = group.HoursPerWeek;
|
HoursPerWeek = group.HoursPerWeek;
|
||||||
IsOwnClass = group.IsOwnClass;
|
IsOwnClass = group.IsOwnClass;
|
||||||
IsDifferentiated = group.IsDifferentiated;
|
IsDifferentiated = group.IsDifferentiated;
|
||||||
|
IsClassTeacher = group.IsClassTeacher;
|
||||||
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||||
WebUntisLessonId = group.WebUntisLessonId;
|
WebUntisLessonId = group.WebUntisLessonId;
|
||||||
OnPropertyChanged(nameof(DialogTitle));
|
OnPropertyChanged(nameof(DialogTitle));
|
||||||
@@ -964,6 +972,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
Result.HoursPerWeek = HoursPerWeek;
|
Result.HoursPerWeek = HoursPerWeek;
|
||||||
Result.IsOwnClass = IsOwnClass;
|
Result.IsOwnClass = IsOwnClass;
|
||||||
Result.IsDifferentiated = IsDifferentiated;
|
Result.IsDifferentiated = IsDifferentiated;
|
||||||
|
Result.IsClassTeacher = IsClassTeacher;
|
||||||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||||
Result.WebUntisLessonId = WebUntisLessonId;
|
Result.WebUntisLessonId = WebUntisLessonId;
|
||||||
_groups.Save(Result);
|
_groups.Save(Result);
|
||||||
|
|||||||
@@ -71,6 +71,9 @@
|
|||||||
<CheckBox Content="Gehört zu meiner Klasse" IsChecked="{Binding IsOwnClass}"
|
<CheckBox Content="Gehört zu meiner Klasse" IsChecked="{Binding IsOwnClass}"
|
||||||
ToolTip.Tip="Für die farbliche Hervorhebung im Dashboard-Kalender. Mehrere Gruppen (Klasse + zugehörige Kurse) können markiert werden."/>
|
ToolTip.Tip="Für die farbliche Hervorhebung im Dashboard-Kalender. Mehrere Gruppen (Klasse + zugehörige Kurse) können markiert werden."/>
|
||||||
|
|
||||||
|
<CheckBox Content="Klassenlehrer/-in dieser Klasse" IsChecked="{Binding IsClassTeacher}"
|
||||||
|
ToolTip.Tip="Blendet den Klassenlehrer-Tab ein (Klassenbucheinträge anderer Lehrkräfte zu dieser Klasse). Sollte nur an genau einer Gruppe gesetzt werden."/>
|
||||||
|
|
||||||
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
|
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
|
||||||
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
|
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<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">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="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)."/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<DatePicker SelectedDate="{Binding StartDate}"/>
|
||||||
|
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||||
|
<DatePicker SelectedDate="{Binding EndDate}"/>
|
||||||
|
<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">
|
||||||
|
<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"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="Kategorie" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="Gruppe" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="Text" FontSize="11" Opacity="0.6"/>
|
||||||
|
</Grid>
|
||||||
|
<ScrollViewer Grid.Row="4">
|
||||||
|
<ItemsControl ItemsSource="{Binding Entries}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ClassTeacherClassRegisterRow">
|
||||||
|
<Grid ColumnDefinitions="70,1*,1.1*,0.8*,1*,1.3*" ColumnSpacing="8" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="0">
|
||||||
|
<TextBlock Text="{Binding Subject}"/>
|
||||||
|
<TextBlock Text="{Binding TeacherUsername}" FontSize="11" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding CategoryName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="{Binding CategoryGroup}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="{Binding Text}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="5" 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">
|
||||||
|
<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"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="Std." FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="Fehlstd." FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="Fehlmin." FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="6" Text="Status" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="7" Text="Grund / Notiz" FontSize="11" Opacity="0.6"/>
|
||||||
|
</Grid>
|
||||||
|
<ScrollViewer Grid.Row="7">
|
||||||
|
<ItemsControl ItemsSource="{Binding AbsenceEntries}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
|
||||||
|
<Grid ColumnDefinitions="70,1*,1.3*,0.5*,0.6*,0.6*,0.8*,1.5*" ColumnSpacing="8" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding SubjectsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding PeriodsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="{Binding TotalAbsentPeriods}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="{Binding TotalAbsentMinutes}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="6" Text="{Binding StatusLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<StackPanel Grid.Column="7" Spacing="0">
|
||||||
|
<TextBlock Text="{Binding ReasonLabel}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding Note}" FontSize="11" Opacity="0.6" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Note, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="8" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
|
public partial class ClassTeacherTabView : UserControl
|
||||||
|
{
|
||||||
|
public ClassTeacherTabView() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -205,6 +205,11 @@
|
|||||||
<views:GroupDocumentationTabView DataContext="{Binding GroupDocumentationTab}"/>
|
<views:GroupDocumentationTabView DataContext="{Binding GroupDocumentationTab}"/>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
||||||
|
<!-- Tab: Klassenlehrer, nur sichtbar für Gruppen mit IsClassTeacher -->
|
||||||
|
<ContentPage Header="Klassenlehrer" IsVisible="{Binding IsClassTeacher}">
|
||||||
|
<views:ClassTeacherTabView DataContext="{Binding ClassTeacherTab}"/>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
</TabbedPage>
|
</TabbedPage>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -244,6 +244,53 @@ public sealed class WebUntisClientTests
|
|||||||
await client.DisposeAsync();
|
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(
|
private static WebUntisClient CreateClient(HttpMessageHandler handler) => new(
|
||||||
new HttpClient(handler),
|
new HttpClient(handler),
|
||||||
new WebUntisOptions
|
new WebUntisOptions
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace LehrerApp.WebUntis;
|
||||||
|
|
||||||
|
public static class WebUntisClassAbsenceParser
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<UntisClassAbsenceEntry> 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<UntisClassAbsenceEntry>();
|
||||||
|
|
||||||
|
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<string, string?>(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<string, string?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -175,6 +175,35 @@ public sealed class WebUntisClient : IAsyncDisposable
|
|||||||
return WebUntisClassRegisterEventReportParser.Parse(reportText);
|
return WebUntisClassRegisterEventReportParser.Parse(reportText);
|
||||||
}, cancellationToken);
|
}, 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<IReadOnlyList<UntisClassAbsenceEntry>> 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<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
|
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
|
||||||
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -147,6 +147,33 @@ public sealed record UntisLessonAbsence(
|
|||||||
string? HandledOn,
|
string? HandledOn,
|
||||||
bool Counts);
|
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
|
// "-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).
|
// Abgleich mit lokalen Schülern nur der Name (siehe Namens-Fallback im Desktop-Abgleich).
|
||||||
public sealed record UntisClassRegisterEventReportEntry(
|
public sealed record UntisClassRegisterEventReportEntry(
|
||||||
|
|||||||
@@ -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
|
Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
|
||||||
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
|
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
|
||||||
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
|
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
|
||||||
- **Idee, noch nicht geplant ("Klassenlehrer"-Feature):** Der "-alle-"-Bericht enthält für die
|
- [x] **"Klassenlehrer"-Feature, Schritt 1 (Klassenbuch fremder Einträge, August 2026):** Der
|
||||||
eigene Klasse auch Einträge, die andere Lehrkräfte in deren Fächern angelegt haben (`Benutzer`
|
"-alle-"-Bericht enthält für die eigene Klasse auch Einträge, die andere Lehrkräfte in deren
|
||||||
≠ eigener Login) — für den reinen Dokumentations-Abgleich (eigene Einträge synchron halten) werden
|
Fächern angelegt haben (`Benutzer` ≠ eigener Login) — für den reinen Dokumentations-Abgleich
|
||||||
die herausgefiltert. Eine Klassenlehrer-Übersicht, die stattdessen genau diese fremden Einträge zur
|
(eigene Einträge synchron halten) werden die herausgefiltert. Neues Flag
|
||||||
eigenen Klasse zeigt, wäre ein eigenständiges, größeres Feature und ist bewusst zurückgestellt.
|
`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
|
||||||
|
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
|
### 4.4 Wochen-/Tagesansicht
|
||||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||||
|
|||||||
Reference in New Issue
Block a user