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
+1
View File
@@ -12,6 +12,7 @@ public class Exam
public List<GradingKeyEntry> GradingKey { get; set; } = [];
public ExamStatus Status { get; set; } = ExamStatus.Planned;
public DateOnly? ReturnedAt { get; set; }
public Niveau? Niveau { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+3
View File
@@ -13,6 +13,7 @@ public class LearningGroup
public int? HoursPerWeek { get; set; }
public bool IsActive { get; set; } = true;
public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -26,7 +27,9 @@ public class Enrollment
public EnrollmentPeriod Period { get; set; } = EnrollmentPeriod.FullYear;
public DateOnly? JoinedAt { get; set; }
public DateOnly? LeftAt { get; set; }
public Niveau? Niveau { get; set; }
}
public enum EnrollmentPeriod { FullYear, H1Only, H2Only, Custom }
public enum GroupType { Class, Course }
public enum GradingSystem { Grades1To6, Points0To15 }
public enum Niveau { E, G, Foerder }
@@ -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;
+16 -3
View File
@@ -31,6 +31,15 @@ Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.
**Niveaudifferenzierung (E/G/Förder) ist bereits umgesetzt** — nicht aus dieser Liste, sondern
ein separater Bedarf (Nachteilsausgleich/Binnendifferenzierung): `LearningGroup.IsDifferentiated`
(Checkbox in den Stammdaten) blendet eine Niveau-Zuordnung je Schüler ein (`Enrollment.Niveau`,
Schüler-Tab). Klausuren bekommen optional ein `Niveau`; Punkteeingabe und Auswertung filtern dann
automatisch auf die passenden Schüler. Workflow: G-Klausur normal anlegen, per "Duplizieren"
(1.1.4) die E-Variante mit eigenen Aufgaben/Punkten ableiten (Niveau wird beim Duplizieren bewusst
zurückgesetzt). Vollständig individuelle Förderklausuren (nur für einen einzelnen Schüler) laufen
pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene Schüler-Zuordnung.
### 1.1 Klausur anlegen und verwalten
- [x] **1.1.1** Dialog `AddExamDialog` — Titel, Datum, Fach, Klausurnummer, Notizen.
Ersetzt den TODO-Stub `AddExam()` in [GroupViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs).
@@ -68,7 +77,7 @@ Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits i
Pfeil-Runter springen zur gleichen Spalte in der Nachbarzeile, direkte Zifferneingabe
und halbe Punkte (Komma oder Punkt) erlaubt.
- [x] **1.4.3** Kennzeichnung "Abwesend" (`ExamResult.Absent`) per Checkbox — Note zeigt "abwesend"
statt einer berechneten Note. Ausschluss aus Statistiken folgt mit 1.5 (noch nicht umgesetzt).
statt einer berechneten Note. Ausschluss aus Statistiken über 1.5 umgesetzt.
- [x] **1.4.4** Kommentarfeld pro Schüler (`ExamResult.Comment`).
- [x] **1.4.5** Validierung: Punkte < 0 oder > Maximalpunkte der Aufgabe wird rot markiert und nicht
gespeichert (`PointsCell.TrySetValue`).
@@ -258,6 +267,8 @@ Navigationspunkt "Arbeitszeit" ist ein `PlaceholderViewModel`.
Grundfunktionen sind vorhanden ([StudentViewModels.cs](LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs),
[GroupViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs)).
Niveau-Zuordnung je Schüler (E/G/Förder, `Enrollment.Niveau`) ist bereits umgesetzt, siehe
Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
### 7.1 Schüler
- [ ] **7.1.1** Schüler löschen / auf inaktiv setzen inkl. Hinweis auf verknüpfte Daten.
@@ -267,7 +278,9 @@ Grundfunktionen sind vorhanden ([StudentViewModels.cs](LehrerApp.Desktop/ViewMod
- [ ] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung.
### 7.2 Gruppen
- [ ] **7.2.1** Gruppe bearbeiten und löschen (aktuell nur Anlegen möglich).
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,
beim Review aber bestätigt.
- [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft,
neues `SchoolYear`, neue `Enrollment`-Einträge.
- [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`Enrollment.LeftAt` setzen statt löschen).
@@ -296,7 +309,7 @@ Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-
### 8.2 Verwendung im Unterricht
- [ ] **8.2.1** Kompetenzen einer Unterrichtseinheit zuordnen (siehe 4.1.3).
- [ ] **8.2.2** Kompetenzen einzelnen Klausuraufgaben zuordnen (siehe 1.2.4).
- [x] **8.2.2** Kompetenzen einzelnen Klausuraufgaben zuordnen — umgesetzt mit 1.2.4.
- [ ] **8.2.3** Abdeckungsübersicht: welche Kompetenzen wurden im Schuljahr behandelt/geprüft?
### 8.3 Kompetenzorientierte Auswertung