Niveaudifferenzierung (E/G/Förder) für Klassen und Kurse

Für Fächer mit Binnendifferenzierung (z.B. Mathematik E/G-Niveau,
Förderniveau) innerhalb derselben Lerngruppe:

- LearningGroup.IsDifferentiated: neue Checkbox in den Stammdaten,
  aktiviert die Niveau-Funktionen optional für Klassen und Kurse.
- Enrollment.Niveau: Niveau-Zuordnung je Schüler innerhalb der Gruppe,
  editierbar über eine neue Spalte im Schüler-Tab (nur sichtbar bei
  differenzierter Gruppe).
- Exam.Niveau: Klausuren können optional einem Niveau zugeordnet
  werden. Workflow nutzt die bestehende Duplizieren-Funktion (1.1.4):
  G-Klausur anlegen, für die E-Variante duplizieren und eigene
  Aufgaben/Punkte vergeben — Niveau wird beim Duplizieren bewusst
  zurückgesetzt, damit nicht versehentlich zwei gleiche entstehen.
- Punkteeingabe und Auswertung filtern automatisch auf die Schüler
  mit passendem Niveau; Klausuren ohne Niveau-Zuordnung verhalten
  sich unverändert (gesamte Gruppe). Beide Dialoge zeigen das Niveau
  als Badge im Titel, die Klausurenliste als eigene Spalte.
- Individuelle Förderklausuren (nur für einen Schüler) laufen
  pragmatisch über die bestehende Abwesend-Markierung der übrigen
  Schüler statt über eine eigene Pro-Schüler-Zuordnung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 02:24:26 +02:00
co-authored by Claude Sonnet 5
parent a22674dc19
commit 482942ce09
13 changed files with 130 additions and 8 deletions
@@ -23,6 +23,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
public string ExamTitle => _exam.Title;
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
public string NiveauLabel => _exam.Niveau.HasValue ? NiveauDisplay.ToName(_exam.Niveau) : "";
[ObservableProperty] private int _gradedCount;
[ObservableProperty] private int _absentCount;
@@ -18,6 +18,7 @@ public partial class ExamGradingDialogViewModel : ObservableObject
public string ExamTitle => _exam.Title;
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
public string NiveauLabel => _exam.Niveau.HasValue ? NiveauDisplay.ToName(_exam.Niveau) : "";
public List<ExamTask> Tasks { get; }
public ObservableCollection<ExamResultRow> Rows { get; } = [];
@@ -38,6 +39,10 @@ public partial class ExamGradingDialogViewModel : ObservableObject
var enrollment = enrollmentList.FirstOrDefault(e => e.StudentId == s.Id);
if (enrollment is not null && !IsEnrolledAtDate(enrollment, exam.Date)) continue;
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
if (exam.Niveau.HasValue && enrollment?.Niveau != exam.Niveau) continue;
existing.TryGetValue(s.Id, out var result);
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, _examMaxPoints, _grading);
row.OnChanged = SaveRow;
@@ -35,6 +35,10 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private GradingKeyTemplate? _selectedTemplate;
[ObservableProperty] private string _newTemplateName = "";
[ObservableProperty] private string _gradingKeyValidation = "";
[ObservableProperty] private string _selectedNiveauName = "";
public bool IsDifferentiated { get; }
public string[] NiveauOptions => NiveauDisplay.Options;
public bool TotalPointsWarning => TotalPoints <= 0;
public string TotalPointsDisplay =>
@@ -55,7 +59,7 @@ public partial class ExamDialogViewModel : ObservableObject
public ExamDialogViewModel(IExamRepository exams, ICompetencyDomainRepository competencyDomains,
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading,
Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem,
string defaultSubjectName, Exam? editingExam, Exam? duplicateSource)
string defaultSubjectName, bool isDifferentiated, Exam? editingExam, Exam? duplicateSource)
{
_exams = exams; _competencyDomains = competencyDomains;
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
@@ -63,6 +67,7 @@ public partial class ExamDialogViewModel : ObservableObject
_gradingSystem = gradingSystem;
_editingExam = editingExam;
_subjectName = defaultSubjectName;
IsDifferentiated = isDifferentiated;
HasCompetencyCatalog = subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel).Count > 0;
@@ -81,6 +86,9 @@ public partial class ExamDialogViewModel : ObservableObject
ExamNumber = source.ExamNumber;
Notes = source.Notes ?? "";
ReturnedAtText = isDuplicate ? "" : source.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
// Beim Duplizieren bewusst kein Niveau übernehmen — meist wird dupliziert, um
// gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen.
SelectedNiveauName = isDuplicate ? "" : NiveauDisplay.ToName(source.Niveau);
foreach (var t in source.Tasks.OrderBy(t => t.Nr))
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.CompetencyCodes]);
UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001);
@@ -285,6 +293,7 @@ public partial class ExamDialogViewModel : ObservableObject
Result.ExamNumber = ExamNumber;
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
Result.ReturnedAt = returnedAt;
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
Result.GradingKey = gradingKey;
_exams.Save(Result);
@@ -221,10 +221,21 @@ public partial class GroupDetailViewModel : ObservableObject
foreach (var s in enrolled)
{
enrollments.TryGetValue(s.Id, out var enr);
Students.Add(new StudentSummary(s, enr));
var summary = new StudentSummary(s, enr) { OnChanged = SaveStudentNiveau };
Students.Add(summary);
}
}
private void SaveStudentNiveau(StudentSummary summary)
{
if (Group is null) return;
var enrollment = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear)
.FirstOrDefault(e => e.StudentId == summary.Id);
if (enrollment is null) return;
enrollment.Niveau = summary.Niveau;
_enrollments.Save(enrollment);
}
[RelayCommand]
private async Task AddStudent()
{
@@ -363,12 +374,47 @@ public partial class GroupDetailViewModel : ObservableObject
[RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); }
}
public class StudentSummary
// ── Niveau-Anzeige (Niveaudifferenzierung) ────────────────────────────────────
public static class NiveauDisplay
{
public static string[] Options { get; } = ["", "E-Niveau", "G-Niveau", "Förderniveau"];
public static string ToName(Niveau? n) => n switch
{
Core.Models.Niveau.E => "E-Niveau",
Core.Models.Niveau.G => "G-Niveau",
Core.Models.Niveau.Foerder => "Förderniveau",
_ => "",
};
public static Niveau? FromName(string? name) => name switch
{
"E-Niveau" => Core.Models.Niveau.E,
"G-Niveau" => Core.Models.Niveau.G,
"Förderniveau" => Core.Models.Niveau.Foerder,
_ => null,
};
}
public partial class StudentSummary : ObservableObject
{
public static string[] NiveauOptions => NiveauDisplay.Options;
public Guid Id { get; }
public string FullName { get; }
public string PeriodLabel { get; }
[ObservableProperty] private Niveau? _niveau;
public string NiveauName
{
get => NiveauDisplay.ToName(Niveau);
set => Niveau = NiveauDisplay.FromName(value);
}
public Action<StudentSummary>? OnChanged { get; set; }
public StudentSummary(Core.Models.Student s, Enrollment? e)
{
Id = s.Id;
@@ -380,6 +426,13 @@ public class StudentSummary
EnrollmentPeriod.Custom => BuildCustomLabel(e),
_ => "",
};
_niveau = e?.Niveau;
}
partial void OnNiveauChanged(Niveau? value)
{
OnPropertyChanged(nameof(NiveauName));
OnChanged?.Invoke(this);
}
private static string BuildCustomLabel(Enrollment e)
@@ -400,11 +453,13 @@ public class ExamSummary
public string StatusLabel { get; }
public string StatusColorHex { get; }
public string ReturnedAtDisplay { get; }
public string NiveauLabel { get; }
public ExamSummary(Core.Models.Exam e)
{
Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status;
ReturnedAtDisplay = e.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle";
StatusLabel = e.Status switch
{
ExamStatus.Planned => "Geplant",
@@ -552,6 +607,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private string _validationMessage = "";
public List<string> SchoolYears { get; }
@@ -589,6 +645,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
SelectedSchoolYear = group.SchoolYear;
HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -629,6 +686,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.SchoolYear = SelectedSchoolYear;
Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
_groups.Save(Result);
if (_editingGroup is not null && _originalSchoolYear != SelectedSchoolYear)
@@ -67,6 +67,9 @@
<CheckBox Content="Gehört zu meiner Klasse" IsChecked="{Binding IsOwnClass}"
ToolTip.Tip="Für die farbliche Hervorhebung im Dashboard-Kalender. Mehrere Gruppen (Klasse + zugehörige Kurse) können markiert werden."/>
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
@@ -21,6 +21,13 @@
<TextBox Text="{Binding Title}" PlaceholderText="z.B. 1. Klausur Kinetik"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding IsDifferentiated}">
<TextBlock Text="Niveau" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding NiveauOptions}" SelectedItem="{Binding SelectedNiveauName}"
HorizontalAlignment="Stretch"
ToolTip.Tip="Strich = für alle Schüler der Gruppe. Bei E/G/Förder zeigt die Punkteeingabe nur die Schüler mit passendem Niveau."/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
@@ -12,7 +12,13 @@
<StackPanel Spacing="16" Margin="0,0,12,0">
<StackPanel Spacing="2">
<TextBlock Text="{Binding ExamTitle}" FontSize="18" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding ExamTitle}" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Border Background="{DynamicResource SystemControlBackgroundAccentBrush}" CornerRadius="4" Padding="8,2"
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
</Border>
</StackPanel>
<TextBlock Text="{Binding ExamDateLabel}" FontSize="12" Opacity="0.6"/>
</StackPanel>
@@ -11,6 +11,10 @@
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="10" Margin="0,0,0,10">
<TextBlock Text="{Binding ExamTitle}" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ExamDateLabel}" FontSize="13" Opacity="0.6" VerticalAlignment="Center"/>
<Border Background="{DynamicResource SystemControlBackgroundAccentBrush}" CornerRadius="4" Padding="8,2"
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
</Border>
</StackPanel>
<DataGrid Grid.Row="1" Name="ResultGrid"
@@ -56,6 +56,16 @@
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="90"/>
<DataGridTemplateColumn Header="Niveau" Width="130"
IsVisible="{Binding Group.IsDifferentiated}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:StudentSummary">
<ComboBox ItemsSource="{x:Static vm:StudentSummary.NiveauOptions}"
SelectedItem="{Binding NiveauName}"
HorizontalAlignment="Stretch" Margin="2"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</ContentPage>
@@ -99,6 +109,8 @@
<DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding Date}" Width="110"/>
<DataGridTextColumn Header="Titel" Binding="{Binding Title}" Width="*"/>
<DataGridTextColumn Header="Niveau" Binding="{Binding NiveauLabel}" Width="80"
IsVisible="{Binding Group.IsDifferentiated}"/>
<DataGridTemplateColumn Header="Status" Width="150">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:ExamSummary">
@@ -62,7 +62,7 @@ public partial class GroupDetailView : UserControl
App.Services.GetRequiredService<IGradingKeyTemplateRepository>(),
App.Services.GetRequiredService<GradingService>(),
groupId, vm.Group.SubjectId, vm.Group.GradeLevel, vm.Group.GradingSystem,
vm.Group.Subject ?? "", editingExam, duplicateSource);
vm.Group.Subject ?? "", vm.Group.IsDifferentiated, editingExam, duplicateSource);
var dialog = new ExamDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;