Klassenlehrer-Tab: Theme-Ressourcen statt Hex-Farben, gestapelter Trend, Fehlquote seit Schuljahresbeginn

Semantische Brushes (Light/Dark) lösen die fest codierten Dark-Hex-Werte ab, die
im hellen Theme unbenutzbar waren. Trend-Chart zeigt jetzt einen echten
gestapelten Balken über die letzten 7 Schultage statt drei unabhängig
skalierten über Kalendertage; neue Fehlquote je Schüler*in seit
Schuljahresbeginn ergänzt den Heute-Snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:47:13 +02:00
co-authored by Claude Sonnet 5
parent cf8de08501
commit 9d375caf14
9 changed files with 510 additions and 119 deletions
@@ -142,7 +142,7 @@ public sealed class ClassTeacherViewModelsTests
Assert.True(row.IsLate); Assert.True(row.IsLate);
Assert.True(row.IsUnexcused); Assert.True(row.IsUnexcused);
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText); Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
Assert.Equal("#FF5A67", row.StatusColor); Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
} }
[Fact] [Fact]
@@ -171,6 +171,66 @@ public sealed class ClassTeacherViewModelsTests
rows.Select(r => r.StudentName)); rows.Select(r => r.StudentName));
} }
[Fact]
public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
{
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
Assert.True(row.HasYearSummary);
Assert.Equal(2, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
Assert.Equal(10, row.YearAbsenceRatePercent);
Assert.Contains("10 %", row.YearSummaryLabel);
Assert.Contains("2 von 20", row.YearSummaryTooltip);
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.False(row.HasYearSummary);
Assert.Equal("", row.YearSummaryLabel);
Assert.Null(row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
yearAbsences, schoolDaysElapsed: 10));
Assert.Equal(1, row.YearAbsenceDayCount);
}
[Fact]
public void TrendDay_BalkenbreitenSummierenSichZurGesamtbreite()
{
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 40d, 20d, 40d);
Assert.Equal(100d, day.TotalBarWidth);
}
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
} }
+8
View File
@@ -2,6 +2,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.App" x:Class="LehrerApp.Desktop.App"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
@@ -0,0 +1,63 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Semantische Pinsel für Flächen und Statusfarben (Klassenlehrer-Bereich, perspektivisch auch
die übrigen Ansichten). Hart codierte Hex-Werte in den Views waren auf das dunkle Theme
zugeschnitten und blieben beim Umschalten auf "Hell" stehen — dunkle Kartenflächen auf hellem
Fenstergrund, Statustexte ohne Kontrast. Über ThemeDictionaries tauscht Avalonia die Werte beim
Theme-Wechsel live aus, sofern die Views sie per DynamicResource (nicht StaticResource) holen.
-->
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Dark">
<!-- Flächen -->
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#11151B"/>
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#2A3038"/>
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#0E1217"/>
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#171D25"/>
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#262C34"/>
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#252B33"/>
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#1C222A"/>
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#4A515C"/>
<!-- Akzent -->
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#5795F5"/>
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#173765"/>
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#66A3FF"/>
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#343B45"/>
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#173765"/>
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#3C86F7"/>
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#FFFFFF"/>
<!-- Status -->
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#73C45A"/>
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#4C8DFF"/>
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#F59E0B"/>
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#FF5A67"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<!-- Flächen: Karten heben sich hier heller vom grauen Fenstergrund ab, nicht dunkler. -->
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#DCE1E8"/>
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#EDF2F9"/>
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#E3E7ED"/>
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#E3E7ED"/>
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#EDF0F5"/>
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#B7BEC9"/>
<!-- Akzent: dunklere Töne, die Akzentfarben aus dem Dark-Theme wären auf Weiß zu blass. -->
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#1B5FC7"/>
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#DCE9FC"/>
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#14509F"/>
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#C7CDD6"/>
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#D7E6FC"/>
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#2C6FD8"/>
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#10243F"/>
<!-- Status -->
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#2E7D32"/>
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#1B5FC7"/>
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#A45B00"/>
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#C0272D"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
@@ -13,8 +13,11 @@ public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject
{ {
public string DateLabel => Date.ToString("dd.MM.yyyy"); public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName => DisplayName(StudentName); public string StudentDisplayName => DisplayName(StudentName);
public string CategoryColor => CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true public ClassTeacherStatusKind CategoryKind =>
? "#FF5A67" : "#4C8DFF"; CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
private static string DisplayName(string value) private static string DisplayName(string value)
{ {
@@ -46,10 +49,6 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order()); public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
public string StatusLabel => string.Join(", ", Statuses); public string StatusLabel => string.Join(", ", Statuses);
public string ReasonLabel => string.Join("; ", AbsenceReasons); public string ReasonLabel => string.Join("; ", AbsenceReasons);
/// Nutzer-Feedback: ein ganzer Fehltag ist in der Liste sonst nicht auf einen Blick erkennbar -
/// dezente Hervorhebung statt einer weiteren Text-/Icon-Spalte.
public string RowBackground => CountsAsFullDay ? "#1FFFA000" : "Transparent";
public string? FullDayTooltip => CountsAsFullDay ? "Ganzer Fehltag" : null;
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r => public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
r.Contains("verspät", StringComparison.OrdinalIgnoreCase)); r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
public bool IsUnexcused => Statuses.Any(s => public bool IsUnexcused => Statuses.Any(s =>
@@ -58,7 +57,10 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}"; CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" : public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel; Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
public string StatusColor => IsUnexcused ? "#FF5A67" : IsLate ? "#F59E0B" : "#73C45A"; public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote } public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
.Where(value => !string.IsNullOrWhiteSpace(value))); .Where(value => !string.IsNullOrWhiteSpace(value)));
@@ -2,10 +2,17 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher; namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im
/// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML
/// (<c>Styles/SemanticBrushes.axaml</c>), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt —
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry) string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{ {
@@ -43,12 +50,30 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
} }
} }
public string StatusColor => IsUnexcused ? "#FF5A67" : public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
HasAbsenceToday ? "#F59E0B" : HasRecentClassRegisterEntry ? "#4C8DFF" : "#73C45A"; HasAbsenceToday ? ClassTeacherStatusKind.Warning :
public string StatusBarColor => StatusColor; HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok;
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info;
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" : public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : ""; HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
public int YearAbsenceDayCount { get; init; }
public int YearUnexcusedDayCount { get; init; }
public int SchoolDaysElapsed { get; init; }
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
public int YearAbsenceRatePercent =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
public string? YearSummaryTooltip => !HasYearSummary ? null :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
private static bool IsLateReason(string reason) => private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
@@ -56,7 +81,9 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
IReadOnlyList<UntisStudentRosterCacheEntry> students, IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences, IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries, IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
DateOnly? today = null) DateOnly? today = null,
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
int schoolDaysElapsed = 0)
{ {
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
@@ -65,6 +92,11 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
.ToDictionary(g => g.Key, g => g.First()); .ToDictionary(g => g.Key, g => g.First());
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)) var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
var yearRows = yearAbsences ?? [];
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
.ToDictionary(g => g.Key, g => g.ToList());
return students.Select(student => return students.Select(student =>
{ {
@@ -72,12 +104,17 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null) var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
?? absenceByName.GetValueOrDefault(nameKey); ?? absenceByName.GetValueOrDefault(nameKey);
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? []; var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? yearByName.GetValueOrDefault(nameKey) ?? [];
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null, return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0) registerEntries.Count > 0)
{ {
TodayAbsence = absence, TodayAbsence = absence,
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate), HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
YearAbsenceDayCount = yearEntries.Count,
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
SchoolDaysElapsed = schoolDaysElapsed,
}; };
}) })
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList(); .OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
@@ -87,14 +124,31 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
} }
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, int LateCount, /// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige
double AlertBarWidth, double UnexcusedBarWidth, double LateBarWidth); /// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
public sealed record ClassTeacherPatternNotice(string StudentName, string Message, string Color); /// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>) und ihre Breiten
/// summieren sich exakt zu <see cref="TotalBarWidth"/>.
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
int LateExcusedCount, int ExcusedCount, double UnexcusedBarWidth, double LateExcusedBarWidth,
double ExcusedBarWidth)
{
public double TotalBarWidth => UnexcusedBarWidth + LateExcusedBarWidth + ExcusedBarWidth;
}
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
ClassTeacherStatusKind Kind)
{
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
}
public partial class ClassTeacherOverviewViewModel : ObservableObject public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
private readonly WebUntisSettingsService _settings; private readonly WebUntisSettingsService _settings;
private readonly UntisReportCacheService _cache; private readonly UntisReportCacheService _cache;
private readonly SchoolYearService _schoolYear;
public ClassTeacherDetailsViewModel DetailsTab { get; } public ClassTeacherDetailsViewModel DetailsTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
@@ -139,10 +193,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab) UntisReportCacheService cache, SchoolYearService schoolYear, ClassTeacherDetailsViewModel detailsTab)
{ {
_settings = settings; _settings = settings;
_cache = cache; _cache = cache;
_schoolYear = schoolYear;
DetailsTab = detailsTab; DetailsTab = detailsTab;
} }
@@ -176,16 +231,24 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
try try
{ {
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
var start = today.AddDays(-6); var sevenDayStart = today.AddDays(-6);
var trendDays = LastSchoolDays(today, 7);
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
var studentsTask = _cache.GetStudentRosterAsync(className); var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, start, today); var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, start, today); var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
var absenceDays = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
var todayAbsences = absenceDays.Where(a => a.Date == today).ToList(); var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
var schoolDaysElapsed = CountWeekdays(yearStart, today);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today)) Roster.Add(row); classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
StudentCount = Roster.Count; StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
@@ -195,8 +258,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused); ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused); UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = classRegisterTask.Result.Count; RecentClassRegisterCount = classRegisterTask.Result.Count;
BuildTrend(absenceDays, start, today); BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(absenceDays); BuildPatternNotices(weekAbsenceDays);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter(); ApplyRosterFilter();
@@ -266,21 +329,50 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
NotifyRosterState(); NotifyRosterState();
} }
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly start, DateOnly end) /// Letzte <paramref name="days"/> statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von
/// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne
/// Ferienkalender — siehe <see cref="LastSchoolDays"/>.
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, IReadOnlyList<DateOnly> days)
{ {
var counts = Enumerable.Range(0, 7).Select(offset => start.AddDays(offset)).Select(date => var today = days[^1];
var counts = days.Select(date =>
{ {
var rows = absenceDays.Where(r => r.Date == date).ToList(); var rows = absenceDays.Where(r => r.Date == date).ToList();
return (Date: date, Alerts: rows.Count, var unexcused = rows.Count(r => r.IsUnexcused);
Unexcused: rows.Count(r => r.Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase))), var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate);
Late: rows.Count(r => r.TotalAbsentPeriods == 0 || r.AbsenceReasons.Any(a => var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate);
a.Contains("verspät", StringComparison.OrdinalIgnoreCase)))); return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused,
Total: unexcused + lateExcused + excused);
}).ToList(); }).ToList();
var max = Math.Max(1, counts.Max(c => c.Alerts)); var max = Math.Max(1, counts.Max(c => c.Total));
foreach (var day in counts) foreach (var day in counts)
TrendDays.Add(new ClassTeacherTrendDay(day.Date == end ? "Heute" : day.Date.ToString("ddd"), TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
day.Alerts, day.Unexcused, day.Late, day.Total, day.Unexcused, day.LateExcused, day.Excused,
112d * day.Alerts / max, 112d * day.Unexcused / max, 112d * day.Late / max)); 112d * day.Unexcused / max, 112d * day.LateExcused / max, 112d * day.Excused / max));
}
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
/// Schulferien, nur Wochenenden.
private static IReadOnlyList<DateOnly> LastSchoolDays(DateOnly end, int count)
{
var days = new List<DateOnly>();
for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1))
if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
days.Add(cursor);
days.Reverse();
return days;
}
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
private static int CountWeekdays(DateOnly start, DateOnly end)
{
if (end < start) return 0;
var count = 0;
for (var d = start; d <= end; d = d.AddDays(1))
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
return count;
} }
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays) private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
@@ -294,10 +386,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate); var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
if (unexcusedDays >= 2) if (unexcusedDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName, PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", "#FF5A67")); $"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
else if (lateDays >= 2) else if (lateDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName, PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{lateDays}-mal verspätet in 7 Tagen", "#F59E0B")); $"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
} }
OnPropertyChanged(nameof(HasPatternNotices)); OnPropertyChanged(nameof(HasPatternNotices));
} }
@@ -3,6 +3,31 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
x:DataType="vm:ClassTeacherDetailsViewModel"> x:DataType="vm:ClassTeacherDetailsViewModel">
<UserControl.Styles>
<!-- Siehe Styles/SemanticBrushes.axaml: Farben per DynamicResource, damit der Theme-Wechsel
sie live austauscht; die Statusfarbe je Zeile kommt als Style-Klasse aus dem ViewModel. -->
<Style Selector="Border.filterCard">
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="10"/>
</Style>
<Style Selector="TextBlock.status">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.status.info">
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="TextBlock.status.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10"> <Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
@@ -10,11 +35,10 @@
<TextBlock Text="Pro Schüler*in und Tag über alle Fächer zusammengefasst" FontSize="12" Opacity="0.55"/> <TextBlock Text="Pro Schüler*in und Tag über alle Fächer zusammengefasst" FontSize="12" Opacity="0.55"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}" <Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="#5795F5" VerticalAlignment="Center"/> IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid> </Grid>
<Border Grid.Row="1" Background="#11151B" BorderBrush="#2A3038" BorderThickness="1" <Border Grid.Row="1" Classes="filterCard">
CornerRadius="8" Padding="10">
<Grid RowDefinitions="Auto,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto" RowSpacing="8">
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8"> <Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}"> <ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
@@ -36,7 +60,7 @@
<Grid Grid.Row="2"> <Grid Grid.Row="2">
<DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid ItemsSource="{Binding AbsenceEntries}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="#2A3038" BorderThickness="1" GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48"> CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="48">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/> <DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
@@ -44,15 +68,20 @@
<DataGridTemplateColumn Header="Art" Width="1.15*"> <DataGridTemplateColumn Header="Art" Width="1.15*">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow"> <DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
<TextBlock Text="{Binding KindLabel}" Foreground="{Binding StatusColor}" VerticalAlignment="Center"/> <TextBlock Text="{Binding KindLabel}" Classes="status"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}" VerticalAlignment="Center"/>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTemplateColumn Header="Status" Width="1*"> <DataGridTemplateColumn Header="Status" Width="1*">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow"> <DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
<Border Background="#1C222A" CornerRadius="5" Padding="7,3" HorizontalAlignment="Left"> <Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="5"
<TextBlock Text="{Binding FriendlyStatusLabel}" Foreground="{Binding StatusColor}" FontSize="11"/> Padding="7,3" HorizontalAlignment="Left">
<TextBlock Text="{Binding FriendlyStatusLabel}" Classes="status"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}" FontSize="11"/>
</Border> </Border>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
@@ -5,10 +5,14 @@
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
x:DataType="vm:ClassTeacherOverviewViewModel"> x:DataType="vm:ClassTeacherOverviewViewModel">
<!-- Flächen- und Statusfarben kommen aus Styles/SemanticBrushes.axaml und werden bewusst per
DynamicResource geholt, damit der Theme-Wechsel (hell/dunkel) sie live austauscht. Die
Statusfarbe einer Zeile setzt deshalb ein Style-Selektor über Klassen und nicht ein
Hex-String aus dem ViewModel — ein lokal gesetztes Attribut würde den Selektor überstimmen. -->
<UserControl.Styles> <UserControl.Styles>
<Style Selector="Border.metric"> <Style Selector="Border.metric">
<Setter Property="Background" Value="#11151B"/> <Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="#2A3038"/> <Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/> <Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,12"/> <Setter Property="Padding" Value="14,12"/>
@@ -16,32 +20,32 @@
</Style> </Style>
<Style Selector="Button.filter"> <Style Selector="Button.filter">
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="#343B45"/> <Setter Property="BorderBrush" Value="{DynamicResource AppFilterBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/> <Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="16,6"/> <Setter Property="Padding" Value="16,6"/>
<Setter Property="FontSize" Value="12"/> <Setter Property="FontSize" Value="12"/>
</Style> </Style>
<Style Selector="Button.filter.active"> <Style Selector="Button.filter.active">
<Setter Property="Background" Value="#173765"/> <Setter Property="Background" Value="{DynamicResource AppFilterActiveBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="#3C86F7"/> <Setter Property="BorderBrush" Value="{DynamicResource AppFilterActiveBorderBrush}"/>
<Setter Property="Foreground" Value="#FFFFFF"/> <Setter Property="Foreground" Value="{DynamicResource AppFilterActiveForegroundBrush}"/>
</Style> </Style>
<Style Selector="Button.rosterRow"> <Style Selector="Button.rosterRow">
<Setter Property="HorizontalAlignment" Value="Stretch"/> <Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/> <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Background" Value="#0E1217"/> <Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="#262C34"/> <Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
<Setter Property="BorderThickness" Value="1,0,1,1"/> <Setter Property="BorderThickness" Value="1,0,1,1"/>
<Setter Property="Padding" Value="0"/> <Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="48"/> <Setter Property="MinHeight" Value="48"/>
</Style> </Style>
<Style Selector="Button.rosterRow:pointerover /template/ ContentPresenter"> <Style Selector="Button.rosterRow:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#171D25"/> <Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
</Style> </Style>
<Style Selector="Border.sidePanel"> <Style Selector="Border.sidePanel">
<Setter Property="Background" Value="#11151B"/> <Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="#2A3038"/> <Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/> <Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14"/> <Setter Property="Padding" Value="14"/>
@@ -52,13 +56,83 @@
<Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="4,10,0,6"/> <Setter Property="Margin" Value="4,10,0,6"/>
</Style> </Style>
<!-- Statusstufe: Basis ist "unauffällig", die Klassen aus dem ViewModel schalten hoch. -->
<Style Selector="TextBlock.status">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.status.info">
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="TextBlock.status.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.statusFill">
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="Border.statusFill.info">
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="Border.statusFill.warning">
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="Border.statusFill.danger">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles> </UserControl.Styles>
<UserControl.Resources>
<!-- Eine Vorlage für beide Roster-Listen (auffällig / übrige): Sie waren Zeichen für Zeichen
identisch und mussten bisher doppelt gepflegt werden. -->
<DataTemplate x:Key="RosterRowTemplate" x:DataType="vm:ClassTeacherRosterRow">
<Button Classes="rosterRow"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,42,220,*,150,18">
<Border Grid.Column="0" Classes="statusFill"
Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}"/>
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14"
BorderBrush="{DynamicResource AppAvatarBorderBrush}" BorderThickness="1"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="{Binding Initials}" FontSize="10"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="2" Text="{Binding StudentName}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<StackPanel Grid.Column="3" VerticalAlignment="Center" Spacing="1"
ToolTip.Tip="{Binding YearSummaryTooltip}">
<TextBlock Text="{Binding StatusText}" Classes="status"
Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}"
FontSize="12" TextTrimming="CharacterEllipsis"/>
<!-- Fehlquote seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein sagt
für Zeugnis/Attestpflicht wenig aus) — als dezente zweite Zeile statt eigener
Spalte, damit das bestehende Layout nicht neu vermessen werden muss. -->
<TextBlock Text="{Binding YearSummaryLabel}" IsVisible="{Binding HasYearSummary}"
FontSize="10" Opacity="0.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="4" Text="{Binding ClassRegisterLabel}"
Foreground="{DynamicResource AppAccentTextBrush}"
VerticalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="5" Text="" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/>
</Grid>
</Button>
</DataTemplate>
</UserControl.Resources>
<Grid> <Grid>
<StackPanel IsVisible="{Binding !HomeroomClassConfigured}" HorizontalAlignment="Center" <StackPanel IsVisible="{Binding !HomeroomClassConfigured}" HorizontalAlignment="Center"
VerticalAlignment="Center" Spacing="12" MaxWidth="380"> VerticalAlignment="Center" Spacing="12" MaxWidth="380">
<Border Width="56" Height="56" CornerRadius="28" Background="#173765" HorizontalAlignment="Center"> <Border Width="56" Height="56" CornerRadius="28" HorizontalAlignment="Center"
<TextBlock Text="KL" FontSize="18" FontWeight="Bold" Foreground="#66A3FF" Background="{DynamicResource AppAccentSoftBackgroundBrush}">
<TextBlock Text="KL" FontSize="18" FontWeight="Bold"
Foreground="{DynamicResource AppAccentOnSoftBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border> </Border>
<TextBlock Text="Noch keine Klasse ausgewählt" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center"/> <TextBlock Text="Noch keine Klasse ausgewählt" FontSize="18" FontWeight="SemiBold" HorizontalAlignment="Center"/>
@@ -80,32 +154,37 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="{Binding LastUpdatedLabel}" FontSize="11" Opacity="0.55" VerticalAlignment="Center"/> <TextBlock Text="{Binding LastUpdatedLabel}" FontSize="11" Opacity="0.55" VerticalAlignment="Center"/>
<Button Content="↻ Aktualisieren" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}" <Button Content="↻ Aktualisieren" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"
Background="Transparent" BorderThickness="0" Foreground="#5795F5"/> Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource AppAccentTextBrush}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,*,*,*"> <Grid Grid.Row="1" ColumnDefinitions="*,*,*,*">
<Border Grid.Column="0" Classes="metric"> <Border Grid.Column="0" Classes="metric">
<Grid ColumnDefinitions="36,*"> <Grid ColumnDefinitions="36,*">
<TextBlock Text="◉" FontSize="22" Foreground="#4C8DFF" VerticalAlignment="Center"/> <TextBlock Text="◉" FontSize="22" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusInfoBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding StudentCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Schüler*innen" FontSize="11" Opacity="0.6"/></StackPanel> <StackPanel Grid.Column="1"><TextBlock Text="{Binding StudentCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Schüler*innen" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid> </Grid>
</Border> </Border>
<Border Grid.Column="1" Classes="metric"> <Border Grid.Column="1" Classes="metric">
<Grid ColumnDefinitions="36,*"> <Grid ColumnDefinitions="36,*">
<TextBlock Text="△" FontSize="24" Foreground="#F59E0B" VerticalAlignment="Center"/> <TextBlock Text="△" FontSize="24" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusWarningBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayAlertCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="heute auffällig" FontSize="11" Opacity="0.6"/></StackPanel> <StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayAlertCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="heute auffällig" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid> </Grid>
</Border> </Border>
<Border Grid.Column="2" Classes="metric"> <Border Grid.Column="2" Classes="metric">
<Grid ColumnDefinitions="36,*"> <Grid ColumnDefinitions="36,*">
<TextBlock Text="!" FontSize="22" Foreground="#FF5A67" VerticalAlignment="Center"/> <TextBlock Text="!" FontSize="22" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusDangerBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayUnexcusedCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="unentschuldigt" FontSize="11" Opacity="0.6"/></StackPanel> <StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayUnexcusedCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="unentschuldigt" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid> </Grid>
</Border> </Border>
<Border Grid.Column="3" Classes="metric" Margin="0"> <Border Grid.Column="3" Classes="metric" Margin="0">
<Grid ColumnDefinitions="36,*"> <Grid ColumnDefinitions="36,*">
<TextBlock Text="▤" FontSize="21" Foreground="#4C8DFF" VerticalAlignment="Center"/> <TextBlock Text="▤" FontSize="21" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusInfoBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding RecentClassRegisterCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Klassenbucheinträge · 7 Tage" FontSize="11" Opacity="0.6"/></StackPanel> <StackPanel Grid.Column="1"><TextBlock Text="{Binding RecentClassRegisterCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="Klassenbucheinträge · 7 Tage" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -113,7 +192,8 @@
<Grid Grid.Row="2" ColumnDefinitions="*,286" ColumnSpacing="12"> <Grid Grid.Row="2" ColumnDefinitions="*,286" ColumnSpacing="12">
<Grid Grid.Column="0" RowDefinitions="Auto,*"> <Grid Grid.Column="0" RowDefinitions="Auto,*">
<Border Grid.Row="0" Background="#11151B" BorderBrush="#2A3038" BorderThickness="1" <Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="8" Margin="0,0,0,4"> CornerRadius="8" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8"> <Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8">
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}" <Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
@@ -129,53 +209,11 @@
<ScrollViewer Grid.Row="1"> <ScrollViewer Grid.Row="1">
<StackPanel> <StackPanel>
<TextBlock Classes="sectionLabel" Text="{Binding PrimarySectionTitle}" IsVisible="{Binding HasPrimaryRoster}"/> <TextBlock Classes="sectionLabel" Text="{Binding PrimarySectionTitle}" IsVisible="{Binding HasPrimaryRoster}"/>
<ItemsControl ItemsSource="{Binding PrimaryRoster}"> <ItemsControl ItemsSource="{Binding PrimaryRoster}" ItemTemplate="{StaticResource RosterRowTemplate}"/>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherRosterRow">
<Button Classes="rosterRow"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,42,220,*,150,18" Margin="0">
<Border Grid.Column="0" Background="{Binding StatusBarColor}"/>
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14" BorderBrush="#4A515C"
BorderThickness="1" HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="{Binding Initials}" FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="2" Text="{Binding StudentName}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" Text="{Binding StatusText}" Foreground="{Binding StatusColor}"
VerticalAlignment="Center" FontSize="12" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="4" Text="{Binding ClassRegisterLabel}" Foreground="#5795F5"
VerticalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="5" Text="" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="sectionLabel" Text="{Binding SecondarySectionTitle}" IsVisible="{Binding HasSecondaryRoster}"/> <TextBlock Classes="sectionLabel" Text="{Binding SecondarySectionTitle}" IsVisible="{Binding HasSecondaryRoster}"/>
<ItemsControl ItemsSource="{Binding SecondaryRoster}"> <ItemsControl ItemsSource="{Binding SecondaryRoster}" ItemTemplate="{StaticResource RosterRowTemplate}"/>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherRosterRow">
<Button Classes="rosterRow"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,42,220,*,150,18">
<Border Grid.Column="0" Background="{Binding StatusBarColor}"/>
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14" BorderBrush="#4A515C" BorderThickness="1"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="{Binding Initials}" FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="2" Text="{Binding StudentName}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" Text="{Binding StatusText}" Foreground="{Binding StatusColor}"
VerticalAlignment="Center" FontSize="12"/>
<TextBlock Grid.Column="4" Text="{Binding ClassRegisterLabel}" Foreground="#5795F5" VerticalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="5" Text="" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel IsVisible="{Binding HasNoFilterResults}" HorizontalAlignment="Center" Margin="0,54,0,0" Spacing="6"> <StackPanel IsVisible="{Binding HasNoFilterResults}" HorizontalAlignment="Center" Margin="0,54,0,0" Spacing="6">
<TextBlock Text="Keine passenden Schüler*innen" FontWeight="SemiBold" HorizontalAlignment="Center"/> <TextBlock Text="Keine passenden Schüler*innen" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Passe Filter oder Suche an." FontSize="12" Opacity="0.55"/> <TextBlock Text="Passe Filter oder Suche an." FontSize="12" Opacity="0.55"/>
@@ -189,10 +227,10 @@
<Border Classes="sidePanel"> <Border Classes="sidePanel">
<StackPanel Spacing="9"> <StackPanel Spacing="9">
<TextBlock Text="Tagesüberblick" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Tagesüberblick" FontSize="14" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Background="#73C45A"/><TextBlock Grid.Column="1" Text="Anwesend" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding PresentCount}"/><TextBlock Grid.Column="3" Text="{Binding PresentPercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> <Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill"/><TextBlock Grid.Column="1" Text="Anwesend" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding PresentCount}"/><TextBlock Grid.Column="3" Text="{Binding PresentPercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid>
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Background="#F59E0B"/><TextBlock Grid.Column="1" Text="Verspätet" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding LateCount}"/><TextBlock Grid.Column="3" Text="{Binding LatePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> <Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/><TextBlock Grid.Column="1" Text="Verspätet" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding LateCount}"/><TextBlock Grid.Column="3" Text="{Binding LatePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid>
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Background="#4C8DFF"/><TextBlock Grid.Column="1" Text="Fehlzeit · entschuldigt" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding ExcusedAbsenceCount}"/><TextBlock Grid.Column="3" Text="{Binding ExcusedAbsencePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> <Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/><TextBlock Grid.Column="1" Text="Fehlzeit · entschuldigt" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding ExcusedAbsenceCount}"/><TextBlock Grid.Column="3" Text="{Binding ExcusedAbsencePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid>
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Background="#FF5A67"/><TextBlock Grid.Column="1" Text="Fehlzeit · unentschuldigt" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding UnexcusedAbsenceCount}"/><TextBlock Grid.Column="3" Text="{Binding UnexcusedAbsencePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> <Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/><TextBlock Grid.Column="1" Text="Fehlzeit · unentschuldigt" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding UnexcusedAbsenceCount}"/><TextBlock Grid.Column="3" Text="{Binding UnexcusedAbsencePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -205,15 +243,38 @@
<Grid ColumnDefinitions="42,*,24" Margin="0,2"> <Grid ColumnDefinitions="42,*,24" Margin="0,2">
<TextBlock Text="{Binding DayLabel}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/> <TextBlock Text="{Binding DayLabel}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
<Grid Grid.Column="1" Height="8" VerticalAlignment="Center"> <Grid Grid.Column="1" Height="8" VerticalAlignment="Center">
<Border Background="#252B33" CornerRadius="4"/> <Border Background="{DynamicResource AppTrackBackgroundBrush}" CornerRadius="4"/>
<Border Width="{Binding AlertBarWidth}" HorizontalAlignment="Left" Background="#F59E0B" CornerRadius="4"/> <!-- Gestapelter Balken: drei sich nicht überschneidende Segmente
(siehe ClassTeacherTrendDay), gemeinsam auf CornerRadius
zugeschnitten statt jedes Segment einzeln abzurunden. -->
<Border Width="{Binding TotalBarWidth}" HorizontalAlignment="Left"
CornerRadius="4" ClipToBounds="True">
<StackPanel Orientation="Horizontal">
<Border Width="{Binding UnexcusedBarWidth}" Classes="statusFill danger"/>
<Border Width="{Binding LateExcusedBarWidth}" Classes="statusFill warning"/>
<Border Width="{Binding ExcusedBarWidth}" Classes="statusFill info"/>
</StackPanel>
</Border>
</Grid> </Grid>
<TextBlock Grid.Column="2" Text="{Binding AlertCount}" FontSize="11" HorizontalAlignment="Right"/> <TextBlock Grid.Column="2" Text="{Binding AlertCount}" FontSize="11" HorizontalAlignment="Right"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Betroffene Schüler*innen pro Tag" FontSize="10" Opacity="0.45"/> <StackPanel Orientation="Horizontal" Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="4">
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/>
<TextBlock Text="Unentsch." FontSize="10" Opacity="0.55"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/>
<TextBlock Text="Verspätet" FontSize="10" Opacity="0.55"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/>
<TextBlock Text="Entschuldigt" FontSize="10" Opacity="0.55"/>
</StackPanel>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -224,7 +285,10 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherPatternNotice"> <DataTemplate x:DataType="vm:ClassTeacherPatternNotice">
<Grid ColumnDefinitions="4,*" Margin="0,3"> <Grid ColumnDefinitions="4,*" Margin="0,3">
<Border Background="{Binding Color}" CornerRadius="2" Margin="0,1,8,1"/> <Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill"
Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}"/>
<StackPanel Grid.Column="1"> <StackPanel Grid.Column="1">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/> <TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/> <TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/>
@@ -3,6 +3,31 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
x:DataType="vm:ClassTeacherDetailsViewModel"> x:DataType="vm:ClassTeacherDetailsViewModel">
<UserControl.Styles>
<!-- Siehe Styles/SemanticBrushes.axaml: Farben per DynamicResource, damit der Theme-Wechsel
sie live austauscht; die Statusfarbe je Zeile kommt als Style-Klasse aus dem ViewModel. -->
<Style Selector="Border.filterCard">
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="10"/>
</Style>
<Style Selector="TextBlock.status">
<Setter Property="Foreground" Value="{DynamicResource AppStatusOkBrush}"/>
</Style>
<Style Selector="TextBlock.status.info">
<Setter Property="Foreground" Value="{DynamicResource AppStatusInfoBrush}"/>
</Style>
<Style Selector="TextBlock.status.warning">
<Setter Property="Foreground" Value="{DynamicResource AppStatusWarningBrush}"/>
</Style>
<Style Selector="TextBlock.status.danger">
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10"> <Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
@@ -10,11 +35,10 @@
<TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht" FontSize="12" Opacity="0.55"/> <TextBlock Text="Einträge anderer Lehrkräfte nur zur Ansicht" FontSize="12" Opacity="0.55"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}" <Button Grid.Column="1" Content="↻ Aus WebUntis aktualisieren" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="#5795F5" VerticalAlignment="Center"/> IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
</Grid> </Grid>
<Border Grid.Row="1" Background="#11151B" BorderBrush="#2A3038" BorderThickness="1" <Border Grid.Row="1" Classes="filterCard">
CornerRadius="8" Padding="10">
<Grid RowDefinitions="Auto,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto" RowSpacing="8">
<Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8"> <Grid Grid.Row="0" ColumnDefinitions="155,Auto,*,Auto,*" ColumnSpacing="8">
<ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}"> <ComboBox Grid.Column="0" SelectedIndex="{Binding QuickRangeIndex, Mode=TwoWay}">
@@ -36,7 +60,7 @@
<Grid Grid.Row="2"> <Grid Grid.Row="2">
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="#2A3038" BorderThickness="1" GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46"> CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/> <DataGridTextColumn Header="Datum" Binding="{Binding DateLabel}" Width="105"/>
+49
View File
@@ -1397,6 +1397,35 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
jetzt `UntisStudentRosterCacheEntry` statt des vollen `UntisStudentDto` entgegen (nur jetzt `UntisStudentRosterCacheEntry` statt des vollen `UntisStudentDto` entgegen (nur
`ExternKey`/`DisplayName` wurden je gebraucht). `ExternKey`/`DisplayName` wurden je gebraucht).
- [x] **"Klassenlehrer"-Feature — Trend & Fehlquote (August 2026):** Zwei Nutzer-Feedback-Punkte
auf einmal umgesetzt, weil sie sich denselben Datenabruf teilen.
- **Trend als gestapelter Balken statt drei unabhängig skalierten:** `ClassTeacherTrendDay`
lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe
Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und
Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt
hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`),
deren Breiten sich exakt zu `TotalBarWidth` summieren; in XAML als `Border ClipToBounds="True"`
mit `CornerRadius` um eine horizontale `StackPanel` aus drei quadratischen Segmenten gerendert,
damit nur die äußeren Kanten abgerundet sind.
- **Trend über die letzten 7 Schultage statt Kalendertage:** an zwei von sieben Tagen war der
Balken bislang systematisch leer, weil `start.AddDays(offset)` Wochenenden mitzählte. Neues
`ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne
Ferienkalender, die App kennt keine Schulferien (gilt auch für die Fehlquote unten).
- **Kumulierte Fehlquote seit Schuljahresbeginn je Schüler*in:** der bisherige Heute-Snapshot
sagt für Zeugnis/Attestpflicht wenig aus. `ClassTeacherRosterRow` bekommt
`YearAbsenceDayCount`/`YearUnexcusedDayCount`/`YearAbsenceRatePercent`
(`SchoolDaysElapsed` als Nenner, wieder nur Werktage ohne Ferienkalender) und zeigt sie als
dezente zweite Zeile unter dem Tagesstatus, Details im Tooltip. Dafür holt `Load()` die
Fehlzeiten jetzt für `[Schuljahresbeginn, heute]` statt nur der letzten 7 Tage — teuer ist das
nur beim ersten Öffnen pro Schuljahr, `UntisReportCacheService` cached den "kalten" Bereich
danach dauerhaft (siehe Cache-Eintrag oben). Klassenbuch-Abruf bleibt bewusst bei 7 Tagen
(für "Eintrag diese Woche"/`RecentClassRegisterCount` reicht das, ein Jahr an fremden
Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen).
Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection,
Service war bereits als Singleton registriert).
### 4.4 Wochen-/Tagesansicht
### 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
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl. ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
@@ -3052,6 +3081,26 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
ca. 23 XAML-Dateien mit fest codierten Hex-Farben statt Theme-Ressourcen — die ca. 23 XAML-Dateien mit fest codierten Hex-Farben statt Theme-Ressourcen — die
Umschaltung selbst funktioniert, aber einzelne Stellen dürften im Dunkelmodus optisch Umschaltung selbst funktioniert, aber einzelne Stellen dürften im Dunkelmodus optisch
nicht passen. Eigene, größere Aufgabe. Schriftgröße nicht umgesetzt. nicht passen. Eigene, größere Aufgabe. Schriftgröße nicht umgesetzt.
**Nachtrag (August 2026) — Theme-Ressourcen, erster Schritt:** Neue
[SemanticBrushes.axaml](LehrerApp.Desktop/Styles/SemanticBrushes.axaml) definiert über
`ResourceDictionary.ThemeDictionaries` (Light/Dark) semantische Pinsel für Flächen
(`AppCardBackgroundBrush`, `AppListRowBackgroundBrush`, `AppChipBackgroundBrush`, …),
Akzent (`AppAccentTextBrush`, `AppFilterActive*Brush`, …) und Status
(`AppStatusOk/Info/Warning/DangerBrush`); eingebunden in `Application.Resources` von
`App.axaml`. Der Klassenlehrer-Bereich ist die erste vollständig umgestellte Ansicht — er
war mit ~36 Hex-Werten der stärkste Fall und im hellen Theme unbrauchbar (dunkle
Kartenflächen auf hellem Grund). Zwei Punkte, die beim Umstellen weiterer Ansichten
genauso gelten:
- Farben müssen per **`DynamicResource`** geholt werden — `StaticResource` löst einmalig
auf und bliebe beim Laufzeit-Wechsel stehen.
- Statusfarben dürfen **nicht als Hex-String aus dem ViewModel** kommen (bisheriges Muster
`StatusColor => "#FF5A67"`), aus demselben Grund. Stattdessen liefert das ViewModel eine
semantische Stufe (`ClassTeacherStatusKind`) plus `Is{Kind}Status`-Flags, und ein
Style-Selektor (`TextBlock.status.danger`, `Border.statusFill.warning`) setzt den Pinsel
— die Basisfarbe muss dabei ebenfalls aus einem Selektor kommen, sonst überstimmt sie das
lokale Attribut (siehe CLAUDE.md, Avalonia-XAML-Fallstricke).
Offen bleiben die übrigen Ansichten (Dashboard, Planung, Mitarbeit, Gruppen …).
- [ ] **12.5** Speicherort der Datenbank anzeigen und ändern. - [ ] **12.5** Speicherort der Datenbank anzeigen und ändern.
- [ ] **12.6** Backup-Verwaltung (siehe 13.3) in den Einstellungen zugänglich machen. - [ ] **12.6** Backup-Verwaltung (siehe 13.3) in den Einstellungen zugänglich machen.