Klassenlehrer-Tab: restliche Quick-Wins (responsive Balken, Tagesüberblick-Chart, Tooltips, Status-Symbol)
Balkenbreiten hingen bisher an einer im ViewModel hart codierten Pixelkonstante; Grid.ColumnDefinitions ließ sich dafür nicht binden (AVLN3000, kein Setter bei kompilierten Bindings), deshalb neuer FractionWidthConverter (MultiBinding gegen die gerenderte Breite einer Track-Border statt Grid-Sternspalten). Tagesüberblick zeigt jetzt denselben gestapelten Balken statt vier Einzelzeilen. AbsenceTooltip und TodayUnexcusedPercent waren berechnet aber ungebunden, jetzt sichtbar. Status-Zeilen bekommen zusätzlich zur Farbe ein vorangestelltes Symbol (Barrierefreiheit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -224,11 +224,44 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TrendDay_BalkenbreitenSummierenSichZurGesamtbreite()
|
public void TrendDay_AnteileSummierenSichZurGesamtquote()
|
||||||
{
|
{
|
||||||
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 40d, 20d, 40d);
|
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d);
|
||||||
|
|
||||||
Assert.Equal(100d, day.TotalBarWidth);
|
Assert.Equal(1.0d, day.TotalFraction, precision: 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran()
|
||||||
|
{
|
||||||
|
var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") };
|
||||||
|
var todayAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90,
|
||||||
|
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90,
|
||||||
|
["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26));
|
||||||
|
|
||||||
|
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind);
|
||||||
|
Assert.StartsWith("✕ ", ada.StatusTextWithGlyph);
|
||||||
|
|
||||||
|
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
|
||||||
|
Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind);
|
||||||
|
Assert.StartsWith("△ ", ben.StatusTextWithGlyph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol()
|
||||||
|
{
|
||||||
|
var students = new[] { Student(1001, "Zora Unauffällig") };
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
|
||||||
|
|
||||||
|
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using LehrerApp.Desktop.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class FractionWidthConverterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Convert_MultipliziertTrackbreiteMitAnteil()
|
||||||
|
{
|
||||||
|
var result = FractionWidthConverter.Instance.Convert(
|
||||||
|
[200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
Assert.Equal(50d, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_LiefertNullBreiteBeiUngueltigenWerten()
|
||||||
|
{
|
||||||
|
// Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) —
|
||||||
|
// darf nicht zu einer negativen oder undefinierten Breite führen.
|
||||||
|
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||||
|
[double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||||
|
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||||
|
["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_ClamptNegativeErgebnisseAufNull()
|
||||||
|
{
|
||||||
|
var result = FractionWidthConverter.Instance.Convert(
|
||||||
|
[100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
Assert.Equal(0d, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Converters;
|
||||||
|
|
||||||
|
/// <summary>Multipliziert die gerenderte Breite eines Referenzelements (typischerweise die neutrale
|
||||||
|
/// Track-Leiste eines gestapelten Balkens) mit einem Anteil 0…1. Ersetzt eine im ViewModel hart
|
||||||
|
/// codierte Pixelkonstante (Quick-Win, Nutzer-Feedback) — die Segmentbreite bleibt damit auch
|
||||||
|
/// korrekt, wenn sich die Breite der Seitenspalte in XAML mal ändert, weil sie sich aus der
|
||||||
|
/// tatsächlichen <c>Bounds.Width</c> des Track-Elements ergibt statt aus einer angenommenen
|
||||||
|
/// festen Breite. `Grid.ColumnDefinitions` ließ sich dafür nicht binden (Avalonia bietet dort bei
|
||||||
|
/// kompilierten Bindings keinen Setter — <c>AVLN3000</c>), deshalb MultiBinding auf Pixelbreite
|
||||||
|
/// statt Grid-Sternspalten.</summary>
|
||||||
|
public sealed class FractionWidthConverter : IMultiValueConverter
|
||||||
|
{
|
||||||
|
public static readonly FractionWidthConverter Instance = new();
|
||||||
|
|
||||||
|
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (values is not [double trackWidth, double fraction, ..] || double.IsNaN(trackWidth)) return 0d;
|
||||||
|
return Math.Max(0d, trackWidth * fraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,16 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
|
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
|
||||||
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
|
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
|
||||||
|
|
||||||
|
/// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des
|
||||||
|
/// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr
|
||||||
|
/// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste).
|
||||||
|
public string StatusTextWithGlyph => StatusKind switch
|
||||||
|
{
|
||||||
|
ClassTeacherStatusKind.Danger => $"✕ {StatusText}",
|
||||||
|
ClassTeacherStatusKind.Warning => $"△ {StatusText}",
|
||||||
|
_ => StatusText,
|
||||||
|
};
|
||||||
|
|
||||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
/// 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.
|
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
||||||
@@ -128,13 +138,22 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
|
/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
|
||||||
/// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
|
/// 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
|
/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
|
||||||
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>) und ihre Breiten
|
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>).
|
||||||
/// summieren sich exakt zu <see cref="TotalBarWidth"/>.
|
|
||||||
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
|
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
|
||||||
int LateExcusedCount, int ExcusedCount, double UnexcusedBarWidth, double LateExcusedBarWidth,
|
int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction,
|
||||||
double ExcusedBarWidth)
|
double ExcusedFraction)
|
||||||
{
|
{
|
||||||
public double TotalBarWidth => UnexcusedBarWidth + LateExcusedBarWidth + ExcusedBarWidth;
|
/// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten
|
||||||
|
/// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart
|
||||||
|
/// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML
|
||||||
|
/// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding
|
||||||
|
/// (<see cref="LehrerApp.Desktop.Converters.FractionWidthConverter"/>) mit der tatsächlich gerenderten Breite
|
||||||
|
/// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere
|
||||||
|
/// Breite bekommt. <c>Grid.ColumnDefinitions</c> ließ sich dafür nicht binden (kein Setter bei
|
||||||
|
/// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten.
|
||||||
|
public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction;
|
||||||
|
public string DetailTooltip =>
|
||||||
|
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||||||
}
|
}
|
||||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||||||
ClassTeacherStatusKind Kind)
|
ClassTeacherStatusKind Kind)
|
||||||
@@ -189,6 +208,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
|
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
|
||||||
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
|
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
|
||||||
|
|
||||||
|
/// Quick-Win: <see cref="TodayUnexcusedPercent"/> wurde berechnet, aber nirgends gebunden —
|
||||||
|
/// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar.
|
||||||
|
public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %";
|
||||||
|
|
||||||
|
/// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken
|
||||||
|
/// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer
|
||||||
|
/// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe
|
||||||
|
/// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in <see cref="Load"/>.
|
||||||
|
public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %";
|
||||||
|
public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %";
|
||||||
|
public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %";
|
||||||
|
public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %";
|
||||||
|
/// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding
|
||||||
|
/// mit der gerenderten Track-Breite, siehe <see cref="ClassTeacherTrendDay.TotalFraction"/>
|
||||||
|
/// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich
|
||||||
|
/// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence.
|
||||||
|
public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount;
|
||||||
|
public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount;
|
||||||
|
public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount;
|
||||||
|
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||||||
|
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||||||
|
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||||||
|
|
||||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||||
|
|
||||||
@@ -348,7 +390,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
foreach (var day in counts)
|
foreach (var day in counts)
|
||||||
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
|
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
|
||||||
day.Total, day.Unexcused, day.LateExcused, day.Excused,
|
day.Total, day.Unexcused, day.LateExcused, day.Excused,
|
||||||
112d * day.Unexcused / max, 112d * day.LateExcused / max, 112d * day.Excused / max));
|
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
|
||||||
@@ -400,6 +442,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
|
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
|
||||||
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
|
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
|
||||||
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
|
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
|
||||||
|
OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel));
|
||||||
|
OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel));
|
||||||
|
OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel));
|
||||||
|
OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction));
|
||||||
|
OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction));
|
||||||
|
OnPropertyChanged(nameof(DayOverviewTooltip));
|
||||||
}
|
}
|
||||||
private void NotifyRosterState()
|
private void NotifyRosterState()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||||
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
|
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
|
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
|
||||||
x:DataType="vm:ClassTeacherOverviewViewModel">
|
x:DataType="vm:ClassTeacherOverviewViewModel">
|
||||||
|
|
||||||
@@ -98,7 +99,8 @@
|
|||||||
Classes.danger="{Binding IsDangerStatus}"/>
|
Classes.danger="{Binding IsDangerStatus}"/>
|
||||||
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14"
|
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14"
|
||||||
BorderBrush="{DynamicResource AppAvatarBorderBrush}" BorderThickness="1"
|
BorderBrush="{DynamicResource AppAvatarBorderBrush}" BorderThickness="1"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
ToolTip.Tip="{Binding AbsenceTooltip}">
|
||||||
<TextBlock Text="{Binding Initials}" FontSize="10"
|
<TextBlock Text="{Binding Initials}" FontSize="10"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -106,7 +108,9 @@
|
|||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<StackPanel Grid.Column="3" VerticalAlignment="Center" Spacing="1"
|
<StackPanel Grid.Column="3" VerticalAlignment="Center" Spacing="1"
|
||||||
ToolTip.Tip="{Binding YearSummaryTooltip}">
|
ToolTip.Tip="{Binding YearSummaryTooltip}">
|
||||||
<TextBlock Text="{Binding StatusText}" Classes="status"
|
<!-- Quick-Win Barrierefreiheit: Status nicht ausschließlich über Farbe, sondern auch
|
||||||
|
über ein vorangestelltes Symbol bei Warnung/Gefahr (StatusTextWithGlyph). -->
|
||||||
|
<TextBlock Text="{Binding StatusTextWithGlyph}" Classes="status"
|
||||||
Classes.info="{Binding IsInfoStatus}"
|
Classes.info="{Binding IsInfoStatus}"
|
||||||
Classes.warning="{Binding IsWarningStatus}"
|
Classes.warning="{Binding IsWarningStatus}"
|
||||||
Classes.danger="{Binding IsDangerStatus}"
|
Classes.danger="{Binding IsDangerStatus}"
|
||||||
@@ -178,7 +182,7 @@
|
|||||||
<Grid ColumnDefinitions="36,*">
|
<Grid ColumnDefinitions="36,*">
|
||||||
<TextBlock Text="!" FontSize="22" VerticalAlignment="Center"
|
<TextBlock Text="!" FontSize="22" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource AppStatusDangerBrush}"/>
|
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="{Binding TodayUnexcusedSummaryLabel}" 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">
|
||||||
@@ -227,10 +231,58 @@
|
|||||||
<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" 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>
|
<!-- Quick-Win: ein gestapelter 100-%-Balken statt vier Einzelzeilen mit
|
||||||
<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>
|
separater Prozentspalte — Verteilung auf einen Blick statt aus vier
|
||||||
<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>
|
Prozentzahlen erschlossen. Jedes Segment multipliziert per MultiBinding
|
||||||
<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>
|
die gerenderte Breite der Track-Leiste (x:Name="DayOverviewTrack") mit
|
||||||
|
seinem Anteil 0…1 (FractionWidthConverter) — bleibt dadurch korrekt, egal
|
||||||
|
wie breit die Seitenspalte in XAML gerade ist. -->
|
||||||
|
<Grid Height="10" ToolTip.Tip="{Binding DayOverviewTooltip}">
|
||||||
|
<Border x:Name="DayOverviewTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
|
||||||
|
CornerRadius="5"/>
|
||||||
|
<Border CornerRadius="5" ClipToBounds="True" HorizontalAlignment="Left">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Border Classes="statusFill">
|
||||||
|
<Border.Width>
|
||||||
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="PresentFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="statusFill warning">
|
||||||
|
<Border.Width>
|
||||||
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="LateFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="statusFill info">
|
||||||
|
<Border.Width>
|
||||||
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="ExcusedAbsenceFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="statusFill danger">
|
||||||
|
<Border.Width>
|
||||||
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="UnexcusedAbsenceFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill"/><TextBlock Text="{Binding PresentSummaryLabel}" FontSize="12"/></StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/><TextBlock Text="{Binding LateSummaryLabel}" FontSize="12"/></StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/><TextBlock Text="{Binding ExcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/><TextBlock Text="{Binding UnexcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -242,17 +294,50 @@
|
|||||||
<DataTemplate x:DataType="vm:ClassTeacherTrendDay">
|
<DataTemplate x:DataType="vm:ClassTeacherTrendDay">
|
||||||
<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="{DynamicResource AppTrackBackgroundBrush}" CornerRadius="4"/>
|
ToolTip.Tip="{Binding DetailTooltip}">
|
||||||
|
<Border x:Name="TrendTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
|
||||||
|
CornerRadius="4"/>
|
||||||
<!-- Gestapelter Balken: drei sich nicht überschneidende Segmente
|
<!-- Gestapelter Balken: drei sich nicht überschneidende Segmente
|
||||||
(siehe ClassTeacherTrendDay), gemeinsam auf CornerRadius
|
(siehe ClassTeacherTrendDay). Jedes Segment multipliziert per
|
||||||
zugeschnitten statt jedes Segment einzeln abzurunden. -->
|
MultiBinding die gerenderte Breite von "TrendTrack" mit seinem
|
||||||
<Border Width="{Binding TotalBarWidth}" HorizontalAlignment="Left"
|
Anteil 0…1 (FractionWidthConverter) — bleibt damit auch dann
|
||||||
CornerRadius="4" ClipToBounds="True">
|
korrekt proportional, wenn die Seitenspalte in XAML mal eine
|
||||||
|
andere Breite bekommt. Der äußere Wrapper ist auf CornerRadius
|
||||||
|
zugeschnitten, damit nur die äußeren Kanten rund sind, nicht
|
||||||
|
jedes Segment einzeln. -->
|
||||||
|
<Border CornerRadius="4" ClipToBounds="True" HorizontalAlignment="Left">
|
||||||
|
<Border.Width>
|
||||||
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="TotalFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<Border Width="{Binding UnexcusedBarWidth}" Classes="statusFill danger"/>
|
<Border Classes="statusFill danger">
|
||||||
<Border Width="{Binding LateExcusedBarWidth}" Classes="statusFill warning"/>
|
<Border.Width>
|
||||||
<Border Width="{Binding ExcusedBarWidth}" Classes="statusFill info"/>
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="UnexcusedFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="statusFill warning">
|
||||||
|
<Border.Width>
|
||||||
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="LateExcusedFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="statusFill info">
|
||||||
|
<Border.Width>
|
||||||
|
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
|
||||||
|
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
|
||||||
|
<Binding Path="ExcusedFraction"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Border.Width>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1403,10 +1403,11 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
|||||||
lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe
|
lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe
|
||||||
Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und
|
Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und
|
||||||
Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt
|
Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt
|
||||||
hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`),
|
hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`);
|
||||||
deren Breiten sich exakt zu `TotalBarWidth` summieren; in XAML als `Border ClipToBounds="True"`
|
in XAML als `Border ClipToBounds="True"` mit `CornerRadius` um eine horizontale `StackPanel`
|
||||||
mit `CornerRadius` um eine horizontale `StackPanel` aus drei quadratischen Segmenten gerendert,
|
aus drei Segmenten gerendert, damit nur die äußeren Kanten abgerundet sind. Wie die
|
||||||
damit nur die äußeren Kanten abgerundet sind.
|
Segmentbreiten selbst berechnet werden, hat sich im Nachtrag unten noch einmal geändert —
|
||||||
|
siehe dort.
|
||||||
- **Trend über die letzten 7 Schultage statt Kalendertage:** an zwei von sieben Tagen war der
|
- **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
|
Balken bislang systematisch leer, weil `start.AddDays(offset)` Wochenenden mitzählte. Neues
|
||||||
`ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne
|
`ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne
|
||||||
@@ -1423,6 +1424,40 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
|||||||
Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen).
|
Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen).
|
||||||
Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection,
|
Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection,
|
||||||
Service war bereits als Singleton registriert).
|
Service war bereits als Singleton registriert).
|
||||||
|
- [x] **"Klassenlehrer"-Feature — restliche Quick-Wins (August 2026):** Die vier verbliebenen
|
||||||
|
Punkte aus derselben Vorschlagsliste wie oben.
|
||||||
|
- **Balkenbreiten responsiv statt fester Pixelkonstante:** `BuildTrend` rechnete bisher mit
|
||||||
|
einer im ViewModel hart codierten Pixelbreite (`112d`), die mit der tatsächlichen Breite der
|
||||||
|
Seitenspalte in XAML synchron gehalten werden musste. Erster Versuch — `Grid.ColumnDefinitions`
|
||||||
|
per `{Binding}` auf einen "N*,N*,…"-String setzen, wie es die eingebaute XAML-Stringsyntax
|
||||||
|
erlaubt — scheiterte an `AVLN3000` ("Unable to find suitable setter"): `ColumnDefinitions` hat
|
||||||
|
bei kompilierten Bindings (`x:DataType`) keinen normalen Property-Setter, auch nicht über einen
|
||||||
|
`IValueConverter`. Stattdessen liefert `ClassTeacherTrendDay`/`ClassTeacherOverviewViewModel`
|
||||||
|
jetzt Anteile 0…1 (`UnexcusedFraction`/`LateExcusedFraction`/`ExcusedFraction` bzw.
|
||||||
|
`Present-/Late-/ExcusedAbsence-/UnexcusedAbsenceFraction`), und die View multipliziert sie per
|
||||||
|
`MultiBinding` mit der tatsächlich gerenderten Breite einer benannten, unsichtbaren
|
||||||
|
Track-`Border` (`ElementName=…, Path=Bounds.Width`) — neuer
|
||||||
|
[FractionWidthConverter.cs](LehrerApp.Desktop/Converters/FractionWidthConverter.cs)
|
||||||
|
(`IMultiValueConverter`). Bleibt dadurch korrekt, unabhängig davon, wie breit die Seitenspalte
|
||||||
|
gerade ist, ohne dass View und ViewModel einen Pixelwert teilen müssten.
|
||||||
|
- **Tagesüberblick als gestapelter 100-%-Balken statt vier Einzelzeilen:** neue `Grid` mit
|
||||||
|
demselben Track-`Border`/`MultiBinding`/`FractionWidthConverter`-Muster wie beim Trend, direkt
|
||||||
|
über der (weiterhin vorhandenen, jetzt kompakteren) Legende. Die vier Kategorien sind exklusiv
|
||||||
|
und summieren sich exakt zu `StudentCount`, brauchen also anders als beim Trend keine
|
||||||
|
Rest-Kategorie für den Sonderfall "unter dem Maximum".
|
||||||
|
- **`AbsenceTooltip` und `TodayUnexcusedPercent` gebunden:** beide wurden berechnet, aber nie
|
||||||
|
angezeigt. `AbsenceTooltip` hängt jetzt als `ToolTip.Tip` am Avatar-Kreis jeder Roster-Zeile;
|
||||||
|
`TodayUnexcusedPercent` steckt in einem neuen `TodayUnexcusedSummaryLabel` unter der
|
||||||
|
"unentschuldigt"-Kennzahlkarte oben. Die vier vormals ungenutzten Prozent-Properties
|
||||||
|
(`Present-/Late-/ExcusedAbsence-/UnexcusedAbsencePercent`) sind jetzt ebenfalls in
|
||||||
|
Summary-Labels der neuen Tagesüberblick-Legende eingebettet, statt einer eigenen
|
||||||
|
rechtsbündigen Spalte wie zuvor.
|
||||||
|
- **Symbol zusätzlich zur Farbe (Barrierefreiheit):** der Status einer Roster-Zeile hing zuvor
|
||||||
|
an der Farbe der linken 4-px-Leiste plus reinem Text ohne visuellen Vorrang. Neues
|
||||||
|
`ClassTeacherRosterRow.StatusTextWithGlyph` stellt "✕ " (Gefahr) bzw. "△ " (Warnung) voran —
|
||||||
|
dieselben Symbole wie in den Kennzahlkarten oben, damit sich die Bedeutung nicht neu erlernen
|
||||||
|
lässt. `StatusText` selbst bleibt unverändert (bestehende Tests/Bindings), das Symbol ist eine
|
||||||
|
zusätzliche, separat getestete Property.
|
||||||
|
|
||||||
### 4.4 Wochen-/Tagesansicht
|
### 4.4 Wochen-/Tagesansicht
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user