3.1.3: Aspekt-Typen Scale3/Binary/Points in Raster und Dialog unterstützt

Neue Klasse ParticipationRatingScale (Core) ist die einzige Quelle für
Rohwerte je Aspekt-Typ. Scale3/Binary liegen bewusst direkt auf derselben
-2..+2-Achse wie Scale5 (nur mit weniger Zwischenschritten), damit die
bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote unverändert
kompatibel bleibt. Points ist grundverschieden (echter Zählwert 0..MaxPoints,
neues Feld auf ParticipationAspect) und wird nur zur Aggregation linear auf
dieselbe Achse normiert.

Ohne diese Normierung hätte ein Punkte-Aspekt die Mitarbeitsnote verfälscht:
drei Stellen summierten bisher den Rohwert direkt (ParticipationGradeDialog-
ViewModel.Recompute, ParticipationWizardViewModels.WeightedRating und
.ComputeSuggestion) - alle drei sind jetzt auf die Normierung umgestellt.

Raster: Punkte-Aspekte zeigen ein NumericUpDown statt fester Stufen-Buttons.
Schnelleingabe-Dialog: Zifferntasten/+/- sind jetzt typabhängig, Legende
zeigt live die für den aktuellen Aspekt gültigen Tasten. Aspekt-Verwaltung
um "Max. Punkte"-Feld ergänzt (nur bei Typ "Punkte" sichtbar).

Bewusst nicht angefasst: die Trendlinien-Visualisierung im
Mitarbeits-Assistenten bleibt fest auf die drei Standardaspekte
zugeschnitten - eigener, größerer Umbau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 00:12:16 +02:00
co-authored by Claude Sonnet 5
parent f8dd567ef8
commit 16ff4875d8
12 changed files with 502 additions and 97 deletions
@@ -35,6 +35,9 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
public ObservableCollection<ParticipationGradeRow> Rows { get; } = [];
public ObservableCollection<AspectWeightItem> AspectWeights { get; } = [];
// Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
// Gewichtung — AspectWeightItem kennt selbst nur Key/Label/Weight, nicht ValueType/MaxPoints.
private readonly Dictionary<string, ParticipationAspect> _aspectsByKey = [];
public ParticipationGradeDialogViewModel(
IParticipationSessionRepository sessions, IParticipationRepository entries,
@@ -63,6 +66,7 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
foreach (var a in all)
{
_aspectsByKey[a.Key] = a;
var item = new AspectWeightItem(a, _aspects);
item.OnChanged = Recompute;
AspectWeights.Add(item);
@@ -105,7 +109,10 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
{
var w = aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
valueSum += r.Value * w;
var normalized = _aspectsByKey.TryGetValue(r.Key, out var aspect)
? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
: r.Value;
valueSum += normalized * w;
weightSum += w;
}
if (weightSum > 0) points.Add((session.Date, valueSum / weightSum));
@@ -383,7 +383,7 @@ public partial class ParticipationStudentRow : ObservableObject
foreach (var a in aspects)
{
var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key);
var cell = new RatingCell(id, a.Key, existing?.Value);
var cell = new RatingCell(id, a.Key, existing?.Value, a.ValueType, a.MaxPoints);
cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val);
Cells.Add(cell);
}
@@ -564,53 +564,64 @@ public partial class RatingCell : ObservableObject
{
public Guid StudentId { get; }
public string AspectKey { get; }
public AspectValueType Type { get; }
public int MaxPoints { get; }
[ObservableProperty] private int? _value;
[ObservableProperty] private string _displayLabel = "";
public Action<Guid, string, int?>? OnChanged { get; set; }
public RatingCell(Guid studentId, string key, int? value)
public RatingCell(Guid studentId, string key, int? value,
AspectValueType type = AspectValueType.Scale5, int maxPoints = 5)
{
StudentId = studentId;
AspectKey = key;
_value = value;
Type = type;
MaxPoints = maxPoints;
_value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value;
UpdateLabel();
}
public void SetValue(int? value)
{
Value = value;
Value = Type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, MaxPoints) : value;
UpdateLabel();
OnChanged?.Invoke(StudentId, AspectKey, value);
OnChanged?.Invoke(StudentId, AspectKey, Value);
}
[RelayCommand]
private void CycleUp()
{
var next = Value is null ? -2 : Math.Min(2, Value.Value + 1);
SetValue(next);
if (Type == AspectValueType.Points)
{
SetValue(Value is null ? 0 : Math.Min(MaxPoints, Value.Value + 1));
return;
}
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = Value is null ? -1 : steps.IndexOf(Value.Value);
SetValue(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
}
[RelayCommand]
private void CycleDown()
{
var next = Value is null ? 2 : Math.Max(-2, Value.Value - 1);
SetValue(next);
if (Type == AspectValueType.Points)
{
SetValue(Value is null ? MaxPoints : Math.Max(0, Value.Value - 1));
return;
}
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = Value is null ? steps.Count : steps.IndexOf(Value.Value);
SetValue(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
}
[RelayCommand]
private void Clear() => SetValue(null);
private void UpdateLabel() => DisplayLabel = Value switch
{
2 => "++",
1 => "+",
0 => "~",
-1 => "",
-2 => "−−",
_ => "",
};
private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
}
// ── Kompetenz-Tags ────────────────────────────────────────────────────────────
@@ -650,7 +661,12 @@ public class AspectColumnDef
{
public string Key { get; }
public string Label { get; }
public AspectColumnDef(ParticipationAspect a) { Key = a.Key; Label = a.Label; }
public AspectValueType ValueType { get; }
public int MaxPoints { get; }
public AspectColumnDef(ParticipationAspect a)
{
Key = a.Key; Label = a.Label; ValueType = a.ValueType; MaxPoints = a.MaxPoints;
}
}
public class ParticipationSessionItem
@@ -712,6 +728,26 @@ public partial class QuickInputViewModel : ObservableObject
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
/// gültige Zifferntasten als Scale5, Points nutzt 0-9 als direkte Punkteingabe statt fester
/// Stufen. Ändert sich mit AspectIndex, siehe OnAspectIndexChanged.
public string HotkeyLegend
{
get
{
var ratingHint = CurrentAspectType() switch
{
AspectValueType.Scale5 => "15 bewerten",
AspectValueType.Scale3 => "13 bewerten",
AspectValueType.Binary => "1 Nein / 2 Ja",
AspectValueType.Points => $"09 Punkte eingeben (bis {CurrentAspectMaxPoints()})",
_ => "15 bewerten",
};
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste nächster Aspekt · " +
"Enter nächster Schüler · Backspace vorheriger · +/ anpassen · Esc schließen";
}
}
public QuickInputViewModel(List<ParticipationStudentRow> rows, List<AspectColumnDef> aspects)
{
_rows = rows;
@@ -719,6 +755,13 @@ public partial class QuickInputViewModel : ObservableObject
if (rows.Any()) ShowStudent(0);
}
partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend));
private AspectValueType CurrentAspectType() =>
_aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType;
private int CurrentAspectMaxPoints() =>
_aspects.Count == 0 ? 5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].MaxPoints;
private void ShowStudent(int index)
{
if (index < 0 || index >= _rows.Count) return;
@@ -731,7 +774,7 @@ public partial class QuickInputViewModel : ObservableObject
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
{
var val = row.GetRating(a.Key);
AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex));
AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex, a.ValueType));
}
UpdateCurrentAspect();
}
@@ -748,26 +791,48 @@ public partial class QuickInputViewModel : ObservableObject
public void SetRatingByNumber(int num)
{
// 1=−−, 2=, 3=~, 4=+, 5=++
var val = num switch { 1 => -2, 2 => -1, 3 => 0, 4 => 1, 5 => 2, _ => (int?)null };
if (val is null) return;
ApplyRating(val.Value);
var type = CurrentAspectType();
if (type == AspectValueType.Points)
{
ApplyRating(Math.Clamp(num, 0, CurrentAspectMaxPoints()));
return;
}
var steps = ParticipationRatingScale.Steps(type);
if (num < 1 || num > steps.Count) return;
ApplyRating(steps[num - 1].Value);
}
public void IncrementRating()
{
var cell = GetCurrentCell();
if (cell is null) return;
var next = cell.Value is null ? -2 : Math.Min(2, cell.Value.Value + 1);
ApplyRating(next);
var type = CurrentAspectType();
if (type == AspectValueType.Points)
{
var max = CurrentAspectMaxPoints();
ApplyRating(cell.Value is null ? 0 : Math.Min(max, cell.Value.Value + 1));
return;
}
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = cell.Value is null ? -1 : steps.IndexOf(cell.Value.Value);
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
}
public void DecrementRating()
{
var cell = GetCurrentCell();
if (cell is null) return;
var next = cell.Value is null ? 2 : Math.Max(-2, cell.Value.Value - 1);
ApplyRating(next);
var type = CurrentAspectType();
if (type == AspectValueType.Points)
{
ApplyRating(cell.Value is null ? CurrentAspectMaxPoints() : Math.Max(0, cell.Value.Value - 1));
return;
}
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = cell.Value is null ? steps.Count : steps.IndexOf(cell.Value.Value);
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
}
private void ApplyRating(int val)
@@ -781,7 +846,7 @@ public partial class QuickInputViewModel : ObservableObject
row.Value = val;
row.UpdateLabel();
}
CurrentValueLabel = RatingLabel(val);
CurrentValueLabel = ParticipationRatingScale.DisplayLabel(CurrentAspectType(), val);
}
public void SelectAspect(int index)
@@ -819,34 +884,28 @@ public partial class QuickInputViewModel : ObservableObject
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key);
}
private static string RatingLabel(int? v) => v switch
{
2 => "++", 1 => "+", 0 => "~", -1 => "", -2 => "−−", _ => "",
};
}
public partial class QuickAspectRow : ObservableObject
{
public int Index { get; }
public string Label { get; }
public AspectValueType Type { get; }
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _displayLabel = "";
public int? Value { get; set; }
public QuickAspectRow(int index, string label, int? value, bool isActive)
public QuickAspectRow(int index, string label, int? value, bool isActive, AspectValueType type = AspectValueType.Scale5)
{
Index = index;
Label = label;
Value = value;
IsActive = isActive;
Type = type;
UpdateLabel();
}
public void UpdateLabel() => DisplayLabel = Value switch
{
2 => "++", 1 => "+", 0 => "~", -1 => "", -2 => "−−", _ => "·",
};
public void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
}
// ── Schnelleingabe Anwesenheit / Hausaufgaben ────────────────────────────────
@@ -935,11 +994,15 @@ public partial class AspectEditItem : ObservableObject
[ObservableProperty] private string _label;
[ObservableProperty] private string _valueTypeName;
[ObservableProperty] private int _maxPoints;
[ObservableProperty] private double _weight;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _errorMessage = "";
public string[] ValueTypeOptions => AspectValueTypeDisplay.Options;
// Nur bei ValueType "Punkte" relevant (3.1.3) — MaxPoints-Feld nur dann in der Verwaltung
// anzeigen, siehe ParticipationAspectsDialog.axaml.
public bool IsPointsType => AspectValueTypeDisplay.FromName(ValueTypeName) == AspectValueType.Points;
public Action<AspectEditItem>? OnDelete { get; set; }
public IRelayCommand DeleteCommand { get; }
public IRelayCommand MoveUpCommand { get; }
@@ -951,6 +1014,7 @@ public partial class AspectEditItem : ObservableObject
_aspect = aspect; _repo = repo;
_label = aspect.Label;
_valueTypeName = AspectValueTypeDisplay.ToName(aspect.ValueType);
_maxPoints = aspect.MaxPoints;
_weight = aspect.Weight;
_isActive = aspect.IsActive;
@@ -979,7 +1043,7 @@ public partial class AspectEditItem : ObservableObject
private void TrySave(Action apply)
{
ErrorMessage = "";
var before = (_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive);
var before = (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive);
apply();
try { _repo.Save(_aspect); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
@@ -988,18 +1052,23 @@ public partial class AspectEditItem : ObservableObject
// die Backing-Felder direkt gesetzt statt der generierten Properties — über die
// Properties würde erneut On{X}Changed feuern und damit wieder TrySave aufrufen.
#pragma warning disable MVVMTK0034
(_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive) = before;
(_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive) = before;
_label = _aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(_aspect.ValueType);
_weight = _aspect.Weight; _isActive = _aspect.IsActive;
_maxPoints = _aspect.MaxPoints; _weight = _aspect.Weight; _isActive = _aspect.IsActive;
#pragma warning restore MVVMTK0034
OnPropertyChanged(nameof(Label)); OnPropertyChanged(nameof(ValueTypeName));
OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive));
OnPropertyChanged(nameof(MaxPoints)); OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive));
ErrorMessage = ex.Message;
}
}
partial void OnLabelChanged(string value) => TrySave(() => _aspect.Label = value);
partial void OnValueTypeNameChanged(string value) => TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value));
partial void OnValueTypeNameChanged(string value)
{
TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value));
OnPropertyChanged(nameof(IsPointsType));
}
partial void OnMaxPointsChanged(int value) => TrySave(() => _aspect.MaxPoints = value);
partial void OnWeightChanged(double value) => TrySave(() => _aspect.Weight = value);
partial void OnIsActiveChanged(bool value) => TrySave(() => _aspect.IsActive = value);
}
@@ -1017,9 +1086,11 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
[ObservableProperty] private string _newKey = "";
[ObservableProperty] private string _newLabel = "";
[ObservableProperty] private string _newValueTypeName = AspectValueTypeDisplay.Options[0];
[ObservableProperty] private int _newMaxPoints = 5;
[ObservableProperty] private string _newAspectError = "";
public string[] ValueTypeOptions => AspectValueTypeDisplay.Options;
public bool IsNewPointsType => AspectValueTypeDisplay.FromName(NewValueTypeName) == AspectValueType.Points;
public ObservableCollection<AspectEditItem> Aspects { get; } = [];
public Func<AspectEditItem, Task<bool>>? OnConfirmDelete { get; set; }
@@ -1029,6 +1100,8 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
Load();
}
partial void OnNewValueTypeNameChanged(string value) => OnPropertyChanged(nameof(IsNewPointsType));
private void Load()
{
Aspects.Clear();
@@ -1069,6 +1142,7 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
Key = NewKey.Trim(),
Label = NewLabel.Trim(),
ValueType = AspectValueTypeDisplay.FromName(NewValueTypeName),
MaxPoints = NewMaxPoints,
SortOrder = Aspects.Count,
};
try { _repo.Save(aspect); }
@@ -1080,7 +1154,7 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject
Aspects.Add(CreateItem(aspect));
RefreshMoveState();
NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0];
NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; NewMaxPoints = 5;
}
private async void DeleteAspectAsync(AspectEditItem item)
@@ -30,6 +30,9 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private readonly List<ParticipationSession> _allSessions;
private readonly List<ParticipationSection> _sectionList;
private readonly Dictionary<string, double> _aspectWeights;
// Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
// Gewichtung, analog zu ParticipationGradeDialogViewModel.
private readonly Dictionary<string, ParticipationAspect> _aspectsByKey;
public string GroupLabel { get; }
@@ -81,10 +84,10 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
GroupLabel = groupLabel;
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
_aspectWeights = aspects.GetDefaults()
.Concat(aspects.GetByGroup(groupId))
.GroupBy(a => a.Key)
.ToDictionary(g => g.Key, g => g.Last().Weight);
var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
.GroupBy(a => a.Key).Select(g => g.Last()).ToList();
_aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight);
_aspectsByKey = applicableAspects.ToDictionary(a => a.Key);
var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear);
_students = students.GetByGroup(groupId)
.Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership)
@@ -221,11 +224,15 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
{
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w;
valueSum += NormalizedValue(r) * w; weightSum += w;
}
return weightSum <= 0 ? null : valueSum / weightSum;
}
private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect)
? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
: r.Value;
private static string RatingLabel(int v) => v switch
{
>= 2 => "++", 1 => "+", 0 => "~", -1 => "", _ => "−−",
@@ -306,7 +313,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
{
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w;
valueSum += NormalizedValue(r) * w; weightSum += w;
}
if (weightSum > 0) points.Add(valueSum / weightSum);
}
@@ -20,23 +20,27 @@
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="70,*,120,80,60,26,26,26">
<Grid ColumnDefinitions="70,*,120,55,80,60,26,26,26">
<TextBlock Grid.Column="0" Text="{Binding Key}" FontSize="12" Opacity="0.6"
VerticalAlignment="Center" Margin="0,0,8,0" TextTrimming="CharacterEllipsis"
ToolTip.Tip="Schlüssel, nach dem Neuanlegen nicht mehr änderbar (Verknüpfung zu bereits erfassten Bewertungen)."/>
<TextBox Grid.Column="1" Text="{Binding Label}" Margin="0,0,6,0"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding ValueTypeOptions}"
SelectedItem="{Binding ValueTypeName}" Margin="0,0,6,0"/>
<NumericUpDown Grid.Column="3" Value="{Binding Weight}" Minimum="0" Maximum="10"
<NumericUpDown Grid.Column="3" Value="{Binding MaxPoints}" Minimum="1" Maximum="100"
FormatString="0" ShowButtonSpinner="False" Margin="0,0,6,0"
IsVisible="{Binding IsPointsType}"
ToolTip.Tip="Maximal erreichbare Punktzahl."/>
<NumericUpDown Grid.Column="4" Value="{Binding Weight}" Minimum="0" Maximum="10"
FormatString="0.0" Increment="0.1" ShowButtonSpinner="False" Margin="0,0,6,0"/>
<CheckBox Grid.Column="4" Content="Aktiv" IsChecked="{Binding IsActive}"
<CheckBox Grid.Column="5" Content="Aktiv" IsChecked="{Binding IsActive}"
VerticalAlignment="Center" Margin="0,0,4,0"
ToolTip.Tip="Deaktivieren statt löschen, damit bereits erfasste Bewertungen dieses Aspekts gültig bleiben."/>
<Button Grid.Column="5" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
<Button Grid.Column="6" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
ToolTip.Tip="Nach oben"/>
<Button Grid.Column="6" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
<Button Grid.Column="7" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
Margin="2,0,0,0" ToolTip.Tip="Nach unten"/>
<Button Grid.Column="7" Content="✕" Command="{Binding DeleteCommand}" Padding="4,2"
<Button Grid.Column="8" Content="✕" Command="{Binding DeleteCommand}" Padding="4,2"
Margin="2,0,0,0" ToolTip.Tip="Endgültig löschen"/>
</Grid>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="11"
@@ -52,7 +56,7 @@
<Separator Margin="0,4"/>
<TextBlock Text="Neuer Aspekt" FontSize="14" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="*,12,*,12,160">
<Grid ColumnDefinitions="*,12,*,12,140,12,90">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Schlüssel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewKey}" PlaceholderText="z.B. experiment"/>
@@ -66,6 +70,11 @@
<ComboBox ItemsSource="{Binding ValueTypeOptions}" SelectedItem="{Binding NewValueTypeName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="6" Spacing="4" IsVisible="{Binding IsNewPointsType}">
<TextBlock Text="Max. Punkte" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewMaxPoints}" Minimum="1" Maximum="100"
FormatString="0" ShowButtonSpinner="False"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding NewAspectError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewAspectError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -49,8 +49,7 @@
<!-- Tastenkürzel-Legende + Schließen-Button -->
<StackPanel Grid.Row="2" Margin="0,16,0,0" Spacing="6">
<TextBlock Opacity="0.35" FontSize="11" TextWrapping="Wrap"
Text="15 bewerten · Q/W/E/R/T Aspekt wählen · Leertaste nächster Aspekt · Enter nächster Schüler · Backspace vorheriger · +/ anpassen · Esc schließen"/>
<TextBlock Opacity="0.35" FontSize="11" TextWrapping="Wrap" Text="{Binding HotkeyLegend}"/>
<Button Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
</StackPanel>
</Grid>
@@ -21,11 +21,17 @@ public partial class ParticipationQuickInputDialog : Window
switch (e.Key)
{
case Key.D0 or Key.NumPad0: vm.SetRatingByNumber(0); e.Handled = true; break;
case Key.D1 or Key.NumPad1: vm.SetRatingByNumber(1); e.Handled = true; break;
case Key.D2 or Key.NumPad2: vm.SetRatingByNumber(2); e.Handled = true; break;
case Key.D3 or Key.NumPad3: vm.SetRatingByNumber(3); e.Handled = true; break;
case Key.D4 or Key.NumPad4: vm.SetRatingByNumber(4); e.Handled = true; break;
case Key.D5 or Key.NumPad5: vm.SetRatingByNumber(5); e.Handled = true; break;
// 6-9 sind nur für Punkte-Aspekte (3.1.3) sinnvoll, siehe QuickInputViewModel.SetRatingByNumber.
case Key.D6 or Key.NumPad6: vm.SetRatingByNumber(6); e.Handled = true; break;
case Key.D7 or Key.NumPad7: vm.SetRatingByNumber(7); e.Handled = true; break;
case Key.D8 or Key.NumPad8: vm.SetRatingByNumber(8); e.Handled = true; break;
case Key.D9 or Key.NumPad9: vm.SetRatingByNumber(9); e.Handled = true; break;
case Key.Q: vm.SelectAspect(0); e.Handled = true; break;
case Key.W: vm.SelectAspect(1); e.Handled = true; break;
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
@@ -103,39 +104,76 @@ public partial class ParticipationTabView : UserControl
: row.Cells.ElementAtOrDefault(cellIndex);
if (cell is null) return new TextBlock();
var panel = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Spacing = 2,
Margin = new Avalonia.Thickness(0, 2),
};
foreach (var (label, val) in new[] { ("−−", -2), ("", -1), ("", 0), ("+", 1), ("++", 2) })
{
var btn = new Button
{
Content = label,
Padding = new Avalonia.Thickness(5, 1),
FontSize = 11,
Opacity = cell.Value == val ? 1.0 : 0.3,
IsEnabled = !isReadOnly,
};
var capturedVal = val;
btn.Click += (_, _) => cell.SetValue(capturedVal);
cell.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(RatingCell.Value))
btn.Opacity = cell.Value == capturedVal ? 1.0 : 0.3;
};
panel.Children.Add(btn);
}
return panel;
// Punkte-Aspekte (3.1.3) sind eine freie Zahl 0..MaxPoints statt einer festen
// Stufenauswahl — dafür ein NumericUpDown statt der Stufen-Buttons unten.
return cell.Type == AspectValueType.Points
? BuildPointsCell(cell, isReadOnly)
: BuildStepButtonsCell(cell, isReadOnly);
});
}
private static Control BuildStepButtonsCell(RatingCell cell, bool isReadOnly)
{
var panel = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Spacing = 2,
Margin = new Avalonia.Thickness(0, 2),
};
foreach (var (val, label) in ParticipationRatingScale.Steps(cell.Type))
{
var btn = new Button
{
Content = label,
Padding = new Avalonia.Thickness(5, 1),
FontSize = 11,
Opacity = cell.Value == val ? 1.0 : 0.3,
IsEnabled = !isReadOnly,
};
var capturedVal = val;
btn.Click += (_, _) => cell.SetValue(capturedVal);
cell.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(RatingCell.Value))
btn.Opacity = cell.Value == capturedVal ? 1.0 : 0.3;
};
panel.Children.Add(btn);
}
return panel;
}
private static Control BuildPointsCell(RatingCell cell, bool isReadOnly)
{
var updown = new NumericUpDown
{
Minimum = 0,
Maximum = cell.MaxPoints,
Increment = 1,
FormatString = "0",
Value = cell.Value,
IsEnabled = !isReadOnly,
Width = 72,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
};
updown.ValueChanged += (_, e) =>
{
var newVal = e.NewValue.HasValue ? (int?)e.NewValue.Value : null;
if (newVal != cell.Value) cell.SetValue(newVal);
};
cell.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName != nameof(RatingCell.Value)) return;
var current = cell.Value.HasValue ? (decimal?)cell.Value.Value : null;
if (updown.Value != current) updown.Value = current;
};
return updown;
}
private static IDataTemplate BuildHomeworkCellTemplate(bool isReadOnly)
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>