diff --git a/LehrerApp.Core/Services/GroupMembershipService.cs b/LehrerApp.Core/Services/GroupMembershipService.cs
new file mode 100644
index 0000000..f3c47c1
--- /dev/null
+++ b/LehrerApp.Core/Services/GroupMembershipService.cs
@@ -0,0 +1,64 @@
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Core.Services;
+
+public enum SchoolYearPeriodKind { FullYear, H1, H2 }
+
+///
+/// Gemeinsame Regeln für zeitlich begrenzte Gruppenzugehörigkeiten.
+/// Eintritt und Austritt gelten unabhängig vom gewählten Halbjahresmodus.
+///
+public static class GroupMembershipService
+{
+ public static bool IsActiveOn(GroupMembership membership, DateOnly date)
+ {
+ if (membership.JoinedAt is { } joinedAt && date < joinedAt) return false;
+ if (membership.LeftAt is { } leftAt && date > leftAt) return false;
+
+ return membership.Period switch
+ {
+ MembershipPeriod.H1Only => IsFirstSemester(date),
+ MembershipPeriod.H2Only => !IsFirstSemester(date),
+ _ => true,
+ };
+ }
+
+ public static bool Overlaps(GroupMembership membership, DateOnly from, DateOnly to)
+ {
+ if (to < from) return false;
+
+ var effectiveFrom = membership.JoinedAt is { } joinedAt && joinedAt > from ? joinedAt : from;
+ var effectiveTo = membership.LeftAt is { } leftAt && leftAt < to ? leftAt : to;
+ if (effectiveTo < effectiveFrom) return false;
+
+ if (membership.Period is MembershipPeriod.FullYear or MembershipPeriod.Custom) return true;
+
+ for (var month = new DateOnly(effectiveFrom.Year, effectiveFrom.Month, 1);
+ month <= effectiveTo;
+ month = month.AddMonths(1))
+ {
+ if (membership.Period == MembershipPeriod.H1Only && IsFirstSemester(month)) return true;
+ if (membership.Period == MembershipPeriod.H2Only && !IsFirstSemester(month)) return true;
+ }
+
+ return false;
+ }
+
+ public static (DateOnly From, DateOnly To) SchoolYearPeriod(
+ string schoolYear, SchoolYearPeriodKind period)
+ {
+ var slash = schoolYear.IndexOf('/');
+ var startText = slash >= 0 ? schoolYear[..slash] : schoolYear;
+ if (!int.TryParse(startText, out var startYear))
+ throw new ArgumentException("Ungültiges Schuljahr.", nameof(schoolYear));
+
+ return period switch
+ {
+ SchoolYearPeriodKind.H1 => (new DateOnly(startYear, 8, 1), new DateOnly(startYear + 1, 1, 31)),
+ SchoolYearPeriodKind.H2 => (new DateOnly(startYear + 1, 2, 1), new DateOnly(startYear + 1, 7, 31)),
+ _ => (new DateOnly(startYear, 8, 1), new DateOnly(startYear + 1, 7, 31)),
+ };
+ }
+
+ private static bool IsFirstSemester(DateOnly date) => date.Month >= 8 || date.Month <= 1;
+}
diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs
index f8ff7f7..c503c00 100644
--- a/LehrerApp.Desktop.Tests/Fakes.cs
+++ b/LehrerApp.Desktop.Tests/Fakes.cs
@@ -21,8 +21,12 @@ public class FakeMemberships(List all) : IGroupMembershipReposi
public List GetByGroup(Guid groupId) => all.Where(m => m.GroupId == groupId).ToList();
public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) =>
all.FirstOrDefault(m => m.StudentId == studentId && m.GroupId == groupId);
- public void Save(GroupMembership membership) { }
- public void Delete(Guid id) { }
+ public void Save(GroupMembership membership)
+ {
+ all.RemoveAll(m => m.Id == membership.Id);
+ all.Add(membership);
+ }
+ public void Delete(Guid id) => all.RemoveAll(m => m.Id == id);
}
public class FakeSessions(List all) : IParticipationSessionRepository
diff --git a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
index 3dfb97d..0c9d732 100644
--- a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
+++ b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
@@ -22,7 +22,8 @@ public class ReportGradeCalculationTests
var grading = new GradingService();
return new ReportGradeDialogViewModel(grades, exams, results, students, memberships,
- schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testgruppe");
+ schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
+ "Testgruppe", "2025/26");
}
private static (Exam Exam1, Exam Exam2, FakeExams Exams, FakeResults Results) BuildExams()
diff --git a/LehrerApp.Desktop.Tests/WithdrawStudentDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/WithdrawStudentDialogViewModelTests.cs
new file mode 100644
index 0000000..59b0461
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/WithdrawStudentDialogViewModelTests.cs
@@ -0,0 +1,39 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.ViewModels.Groups;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public class WithdrawStudentDialogViewModelTests
+{
+ [Fact]
+ public void InitialisiertMitHeutigemDatumUndSpeichertAustritt()
+ {
+ var membership = new GroupMembership { JoinedAt = new DateOnly(2025, 9, 1) };
+ var memberships = new FakeMemberships([membership]);
+ var vm = new WithdrawStudentDialogViewModel(
+ memberships, membership, "Anna Beispiel", "7a", new DateOnly(2025, 10, 5));
+
+ Assert.Equal(new DateOnly(2025, 10, 5), DateOnly.FromDateTime(vm.SelectedDate!.Value.LocalDateTime));
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.True(vm.WasSaved);
+ Assert.Equal(new DateOnly(2025, 10, 5), membership.LeftAt);
+ }
+
+ [Fact]
+ public void VerhindertAustrittVorEintritt()
+ {
+ var membership = new GroupMembership { JoinedAt = new DateOnly(2025, 9, 1) };
+ var vm = new WithdrawStudentDialogViewModel(
+ new FakeMemberships([membership]), membership, "Anna Beispiel", "7a");
+ vm.SelectedDate = new DateTimeOffset(2025, 8, 31, 0, 0, 0, TimeSpan.Zero);
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.False(vm.WasSaved);
+ Assert.Null(membership.LeftAt);
+ Assert.NotEmpty(vm.DateError);
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
index cfd678b..846688f 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
@@ -37,7 +37,7 @@ public partial class ExamGradingDialogViewModel : ObservableObject
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
- if (membership is not null && !IsMemberAtDate(membership, exam.Date)) continue;
+ if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
@@ -52,14 +52,6 @@ public partial class ExamGradingDialogViewModel : ObservableObject
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
- private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch
- {
- MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1,
- MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7,
- MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value)
- && (membership.LeftAt is null || date <= membership.LeftAt.Value),
- _ => true,
- };
}
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
index 2a1f725..58243ce 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
@@ -23,6 +23,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
private GradingSystem _gradingSystem;
private GroupType _groupType;
private string _groupLabel = "";
+ private string _schoolYear = "";
private bool _sortByTotal;
private bool _sortDescending;
@@ -30,6 +31,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
public GradingSystem GradingSystem => _gradingSystem;
public GroupType GroupType => _groupType;
public string GroupLabel => _groupLabel;
+ public string SchoolYear => _schoolYear;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private bool _showAsPoints = true;
@@ -61,12 +63,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
_selectedPeriod = PeriodOptions[0];
}
- public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType, string groupLabel)
+ public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType,
+ string groupLabel, string schoolYear)
{
_groupId = groupId;
_gradingSystem = gradingSystem;
_groupType = groupType;
_groupLabel = groupLabel;
+ _schoolYear = schoolYear;
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
OnPropertyChanged(nameof(CanTogglePointsView));
Recompute();
@@ -119,18 +123,24 @@ public partial class GradeOverviewTabViewModel : ObservableObject
private void Recompute()
{
var period = SelectedPeriod.Period;
+ var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
+ {
+ ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
+ ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
+ _ => SchoolYearPeriodKind.FullYear,
+ });
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
var exams = _exams.GetByGroup(_groupId)
- .Where(e => InPeriod(e.Date, period))
+ .Where(e => e.Date >= periodFrom && e.Date <= periodTo)
.OrderBy(e => e.Date)
.ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var otherGrades = _grades.GetByGroup(_groupId)
- .Where(g => InPeriod(g.Date, period))
+ .Where(g => g.Date >= periodFrom && g.Date <= periodTo)
.ToList();
var gradeColumnKeys = otherGrades
.Select(g => (g.Category, Note: g.Note ?? "", g.Date))
@@ -153,13 +163,15 @@ public partial class GradeOverviewTabViewModel : ObservableObject
foreach (var student in students)
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
- if (!StudentActiveInPeriod(membership, period)) continue;
+ if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var cells = new List();
var numeric = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
+ if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date))
+ { cells.Add(""); continue; }
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) { cells.Add(""); continue; }
resultsByExam[exam.Id].TryGetValue(student.Id, out var result);
if (result is null) { cells.Add(""); continue; }
@@ -171,6 +183,8 @@ public partial class GradeOverviewTabViewModel : ObservableObject
foreach (var key in gradeColumnKeys)
{
+ if (membership is not null && !GroupMembershipService.IsActiveOn(membership, key.Date))
+ { cells.Add(""); continue; }
var grade = otherGrades.FirstOrDefault(g =>
g.StudentId == student.Id && g.Category == key.Category &&
(g.Note ?? "") == key.Note && g.Date == key.Date);
@@ -206,23 +220,6 @@ public partial class GradeOverviewTabViewModel : ObservableObject
for (var i = 0; i < sorted.Count; i++) Rows.Move(Rows.IndexOf(sorted[i]), i);
}
- private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
- {
- ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
- ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
- _ => true,
- };
-
- private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
- {
- if (period == ParticipationPeriod.FullYear || m is null) return true;
- return m.Period switch
- {
- MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
- MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
- _ => true,
- };
- }
}
public class GradeOverviewColumnDef(string header)
@@ -404,6 +401,8 @@ public partial class GradeEditItem : ObservableObject
public partial class CollectiveGradeDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
+ private readonly IGroupMembershipRepository _memberships;
+ private readonly List _students;
private readonly Guid _groupId;
[ObservableProperty] private GradeCategory _category = GradeCategory.Other;
@@ -422,11 +421,36 @@ public partial class CollectiveGradeDialogViewModel : ObservableObject
public ObservableCollection Rows { get; } = [];
- public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students, Guid groupId)
+ public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students,
+ IGroupMembershipRepository memberships, Guid groupId)
{
- _grades = grades; _groupId = groupId;
- foreach (var s in students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
- Rows.Add(new CollectiveGradeStudentRow(s.Id, s.FullName));
+ _grades = grades; _memberships = memberships; _groupId = groupId;
+ _students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
+ RebuildRows(DateOnly.FromDateTime(DateTime.Today));
+ }
+
+ partial void OnDateTextChanged(string value)
+ {
+ if (DateOnly.TryParseExact(value, "dd.MM.yyyy", null,
+ System.Globalization.DateTimeStyles.None, out var date))
+ RebuildRows(date);
+ }
+
+ private void RebuildRows(DateOnly date)
+ {
+ var previousValues = Rows.ToDictionary(r => r.StudentId, r => r.Value);
+ var membershipByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
+ Rows.Clear();
+ foreach (var student in _students)
+ {
+ if (membershipByStudent.TryGetValue(student.Id, out var membership)
+ && !GroupMembershipService.IsActiveOn(membership, date))
+ continue;
+ Rows.Add(new CollectiveGradeStudentRow(student.Id, student.FullName)
+ {
+ Value = previousValues.GetValueOrDefault(student.Id, ""),
+ });
+ }
}
[RelayCommand]
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
index 1755ae6..fca9cc8 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
@@ -169,6 +169,7 @@ public partial class GroupDetailViewModel : ObservableObject
[ObservableProperty] private int _activeTabIndex = 0;
[ObservableProperty] private StudentSummary? _selectedStudent;
[ObservableProperty] private ExamSummary? _selectedExam;
+ [ObservableProperty] private bool _showFormerStudents;
// Eigene Property statt "Group.IsDifferentiated" im Binding-Pfad: die View bindet bereits,
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
@@ -177,6 +178,7 @@ public partial class GroupDetailViewModel : ObservableObject
public string SubjectName { get; private set; } = "";
partial void OnGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(IsDifferentiated));
+ partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
public ObservableCollection Students { get; } = [];
public ObservableCollection Exams { get; } = [];
@@ -185,6 +187,7 @@ public partial class GroupDetailViewModel : ObservableObject
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
public Func>? OnAddStudent { get; set; }
+ public Func>? OnWithdrawStudent { get; set; }
public Func>? OnAddExam { get; set; }
public Func>? OnEditExam { get; set; }
public Func>? OnDuplicateExam { get; set; }
@@ -219,7 +222,7 @@ public partial class GroupDetailViewModel : ObservableObject
LoadStudents();
ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
- GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
+ GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear);
PlanningTab.Initialize(Group.Id);
}
@@ -238,11 +241,14 @@ public partial class GroupDetailViewModel : ObservableObject
Students.Clear();
var enrolled = _students.GetByGroup(Group.Id);
var memberships = _memberships.GetByGroup(Group.Id).ToDictionary(e => e.StudentId);
- StudentCount = enrolled.Count;
- foreach (var s in enrolled)
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ StudentCount = memberships.Values.Count(m => GroupMembershipService.IsActiveOn(m, today));
+ foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
memberships.TryGetValue(s.Id, out var membership);
- var summary = new StudentSummary(s, membership) { OnChanged = SaveStudentNiveau };
+ if (membership is null) continue;
+ var summary = new StudentSummary(s, membership, today) { OnChanged = SaveStudentNiveau };
+ if (summary.IsFormer && !ShowFormerStudents) continue;
Students.Add(summary);
}
}
@@ -269,21 +275,36 @@ public partial class GroupDetailViewModel : ObservableObject
}
[RelayCommand(CanExecute = nameof(HasSelectedStudent))]
- private void RemoveStudent()
+ private async Task WithdrawStudent()
{
- if (Group is null || SelectedStudent is null) return;
- var membership = _memberships.GetByStudentAndGroup(SelectedStudent.Id, Group.Id);
- if (membership is null) return;
- _memberships.Delete(membership.Id);
+ if (SelectedStudent is null || OnWithdrawStudent is null) return;
+ if (!await OnWithdrawStudent(SelectedStudent)) return;
LoadStudents();
SelectedStudent = null;
ParticipationTab.RefreshCurrentGrid();
}
- partial void OnSelectedStudentChanged(StudentSummary? value) =>
- RemoveStudentCommand.NotifyCanExecuteChanged();
+ [RelayCommand(CanExecute = nameof(CanReinstateSelectedStudent))]
+ private void ReinstateStudent()
+ {
+ if (Group is null || SelectedStudent is null) return;
+ var membership = _memberships.GetByStudentAndGroup(SelectedStudent.Id, Group.Id);
+ if (membership is null) return;
+ membership.LeftAt = null;
+ _memberships.Save(membership);
+ LoadStudents();
+ SelectedStudent = null;
+ ParticipationTab.RefreshCurrentGrid();
+ }
+
+ partial void OnSelectedStudentChanged(StudentSummary? value)
+ {
+ WithdrawStudentCommand.NotifyCanExecuteChanged();
+ ReinstateStudentCommand.NotifyCanExecuteChanged();
+ }
private bool HasSelectedStudent() => SelectedStudent is not null;
+ private bool CanReinstateSelectedStudent() => SelectedStudent?.HasExitDate == true;
[RelayCommand]
private async Task AddExam()
@@ -423,6 +444,10 @@ public partial class StudentSummary : ObservableObject
public Guid Id { get; }
public string FullName { get; }
public string PeriodLabel { get; }
+ public bool IsFormer { get; }
+ public bool HasExitDate { get; }
+ public string MembershipStatus { get; }
+ public string WithdrawActionLabel => HasExitDate ? "Austrittsdatum ändern" : "Austragen";
[ObservableProperty] private Niveau? _niveau;
@@ -434,17 +459,17 @@ public partial class StudentSummary : ObservableObject
public Action? OnChanged { get; set; }
- public StudentSummary(Core.Models.Student s, GroupMembership? membership)
+ public StudentSummary(Core.Models.Student s, GroupMembership? membership, DateOnly? today = null)
{
Id = s.Id;
FullName = s.FullName;
- PeriodLabel = membership?.Period switch
- {
- MembershipPeriod.H1Only => "H1",
- MembershipPeriod.H2Only => "H2",
- MembershipPeriod.Custom => BuildCustomLabel(membership),
- _ => "",
- };
+ PeriodLabel = membership is null ? "" : BuildPeriodLabel(membership);
+ HasExitDate = membership?.LeftAt.HasValue == true;
+ IsFormer = membership?.LeftAt is { } leftAt
+ && leftAt < (today ?? DateOnly.FromDateTime(DateTime.Today));
+ MembershipStatus = membership?.LeftAt is { } exitDate
+ ? IsFormer ? $"Ausgetreten am {exitDate:dd.MM.yyyy}" : $"Austritt am {exitDate:dd.MM.yyyy}"
+ : "Aktiv";
_niveau = membership?.Niveau;
}
@@ -454,12 +479,20 @@ public partial class StudentSummary : ObservableObject
OnChanged?.Invoke(this);
}
- private static string BuildCustomLabel(GroupMembership membership)
+ private static string BuildPeriodLabel(GroupMembership membership)
{
- if (membership.JoinedAt.HasValue && membership.LeftAt.HasValue) return $"{membership.JoinedAt:dd.MM.}–{membership.LeftAt:dd.MM.}";
- if (membership.JoinedAt.HasValue) return $"ab {membership.JoinedAt:dd.MM.}";
- if (membership.LeftAt.HasValue) return $"bis {membership.LeftAt:dd.MM.}";
- return "Datum";
+ var period = membership.Period switch
+ {
+ MembershipPeriod.H1Only => "H1",
+ MembershipPeriod.H2Only => "H2",
+ MembershipPeriod.Custom => "Datum",
+ _ => "Ganzes Jahr",
+ };
+ if (membership.JoinedAt.HasValue && membership.LeftAt.HasValue)
+ return $"{period} · {membership.JoinedAt:dd.MM.}–{membership.LeftAt:dd.MM.}";
+ if (membership.JoinedAt.HasValue) return $"{period} · ab {membership.JoinedAt:dd.MM.}";
+ if (membership.LeftAt.HasValue) return $"{period} · bis {membership.LeftAt:dd.MM.}";
+ return period;
}
}
@@ -591,6 +624,63 @@ public class StudentPickerItem
public StudentPickerItem(Student s) { Id = s.Id; FullName = s.FullName; }
}
+// ── Dialog: Schüler aus einer Lerngruppe austragen ───────────────────────────
+
+public partial class WithdrawStudentDialogViewModel : ObservableObject
+{
+ private readonly IGroupMembershipRepository _memberships;
+ private readonly GroupMembership _membership;
+
+ [ObservableProperty] private DateTimeOffset? _selectedDate;
+ [ObservableProperty] private string _dateError = "";
+
+ public string StudentName { get; }
+ public string GroupName { get; }
+ public DateTimeOffset? EarliestDate { get; }
+ public bool WasSaved { get; private set; }
+
+ public WithdrawStudentDialogViewModel(IGroupMembershipRepository memberships,
+ GroupMembership membership, string studentName, string groupName, DateOnly? today = null)
+ {
+ _memberships = memberships;
+ _membership = membership;
+ StudentName = studentName;
+ GroupName = groupName;
+
+ var initialDate = membership.LeftAt ?? today ?? DateOnly.FromDateTime(DateTime.Today);
+ _selectedDate = ToDateTimeOffset(initialDate);
+ EarliestDate = membership.JoinedAt is { } joinedAt ? ToDateTimeOffset(joinedAt) : null;
+ }
+
+ [RelayCommand]
+ private void Save()
+ {
+ DateError = "";
+ if (SelectedDate is null)
+ {
+ DateError = "Bitte ein Austrittsdatum auswählen.";
+ return;
+ }
+
+ var exitDate = DateOnly.FromDateTime(SelectedDate.Value.LocalDateTime);
+ if (_membership.JoinedAt is { } joinedAt && exitDate < joinedAt)
+ {
+ DateError = $"Das Austrittsdatum darf nicht vor dem Eintritt am {joinedAt:dd.MM.yyyy} liegen.";
+ return;
+ }
+
+ _membership.LeftAt = exitDate;
+ _memberships.Save(_membership);
+ WasSaved = true;
+ }
+
+ private static DateTimeOffset ToDateTimeOffset(DateOnly date)
+ {
+ var localDateTime = date.ToDateTime(TimeOnly.MinValue);
+ return new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime));
+ }
+}
+
// ── Dialog: Neue Lerngruppe anlegen ──────────────────────────────────────────
public partial class AddGroupDialogViewModel : ObservableObject
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs
index 42c8f83..8ffb587 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs
@@ -90,7 +90,7 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == student.Id);
var relevantSessions = sessions
- .Where(s => membership is null || IsMemberAtDate(membership, s.Date))
+ .Where(s => membership is null || GroupMembershipService.IsActiveOn(membership, s.Date))
.ToList();
var points = new List<(DateOnly Date, double Rating)>();
@@ -152,14 +152,6 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject
_ => true,
};
- private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch
- {
- MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1,
- MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7,
- MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value)
- && (membership.LeftAt is null || date <= membership.LeftAt.Value),
- _ => true,
- };
}
// ── Gewichtung eines Aspekts (3.2.1) ─────────────────────────────────────────
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
index 8244ca7..6a3df2b 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
using System.Collections.Generic;
@@ -140,7 +141,7 @@ public partial class ParticipationTabViewModel : ObservableObject
foreach (var s in students)
{
var membership = memberships.FirstOrDefault(e => e.StudentId == s.Id);
- if (membership is not null && !IsMemberAtDate(membership, sessionDate))
+ if (membership is not null && !GroupMembershipService.IsActiveOn(membership, sessionDate))
continue;
var entry = entries.FirstOrDefault(e => e.StudentId == s.Id)
@@ -157,15 +158,6 @@ public partial class ParticipationTabViewModel : ObservableObject
RebuildColumnsSignal++;
}
- private static bool IsMemberAtDate(GroupMembership membership, DateOnly date) => membership.Period switch
- {
- MembershipPeriod.H1Only => date.Month >= 8 || date.Month <= 1,
- MembershipPeriod.H2Only => date.Month >= 2 && date.Month <= 7,
- MembershipPeriod.Custom => (membership.JoinedAt is null || date >= membership.JoinedAt.Value)
- && (membership.LeftAt is null || date <= membership.LeftAt.Value),
- _ => true,
- };
-
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
index 0720e27..fa60c21 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
@@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IGradeRepository _grades;
+ private readonly Dictionary _membershipsByStudent;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly string _schoolYear;
@@ -69,7 +70,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
public ParticipationWizardDialogViewModel(
IParticipationSessionRepository sessions, IParticipationRepository entries,
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
- IStudentRepository students, IExamRepository exams, IExamResultRepository results,
+ IStudentRepository students, IGroupMembershipRepository memberships,
+ IExamRepository exams, IExamResultRepository results,
IGradeRepository grades, GradingService grading,
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
{
@@ -77,12 +79,17 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
_exams = exams; _results = results; _grades = grades; _grading = grading;
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
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);
- _students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
+ var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear);
+ _students = students.GetByGroup(groupId)
+ .Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership)
+ || GroupMembershipService.Overlaps(membership, schoolYearRange.From, schoolYearRange.To))
+ .OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
_allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
_sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
@@ -123,17 +130,20 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
foreach (var session in _allSessions)
{
+ if (!IsStudentActiveOn(studentId, session.Date)) continue;
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
points.Add((session.Date, BuildSessionPoint(session, entry, studentId)));
}
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
{
+ if (!IsStudentActiveOn(studentId, exam.Date)) continue;
var result = _results.GetByExamAndStudent(exam.Id, studentId);
if (result is null) continue;
points.Add((exam.Date, BuildExamPoint(exam, result)));
}
foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
- .Where(g => g.Category != GradeCategory.Participation))
+ .Where(g => g.Category != GradeCategory.Participation)
+ .Where(g => IsStudentActiveOn(studentId, g.Date)))
{
points.Add((grade.Date, BuildOtherGradePoint(grade)));
}
@@ -269,6 +279,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
foreach (var section in _sectionList)
{
+ if (!StudentOverlaps(studentId, section.StartDate, section.EndDate)) continue;
var grade = studentGrades.FirstOrDefault(g => g.Note == AbschnittPrefix + section.Label);
var row = new WizardSectionRow(section.Label, section.StartDate, section.EndDate,
grade?.Value ?? "", isOpen: false);
@@ -285,7 +296,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
{
var points = new List();
- foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end))
+ foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end)
+ .Where(s => IsStudentActiveOn(studentId, s.Date)))
{
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
if (entry is null || entry.Ratings.Count == 0) continue;
@@ -375,6 +387,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
var sectionGrades = _grades.GetByStudentAndGroup(student.Id, _groupId)
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
.Where(g => InPeriod(g.Date, RollupPeriod.Period))
+ .Where(g => IsStudentActiveOn(student.Id, g.Date))
.Select(g => (g.Value, g.Weight))
.ToList();
if (sectionGrades.Count == 0) continue;
@@ -401,6 +414,14 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
+
+ private bool IsStudentActiveOn(Guid studentId, DateOnly date) =>
+ !_membershipsByStudent.TryGetValue(studentId, out var membership)
+ || GroupMembershipService.IsActiveOn(membership, date);
+
+ private bool StudentOverlaps(Guid studentId, DateOnly from, DateOnly to) =>
+ !_membershipsByStudent.TryGetValue(studentId, out var membership)
+ || GroupMembershipService.Overlaps(membership, from, to);
}
// ── Zeitleisten-Bausteine ────────────────────────────────────────────────────
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
index 1f0be7c..9bdfe9d 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
@@ -25,6 +25,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject
private readonly GroupType _groupType;
private readonly GradingSystem _gradingSystem;
private readonly string _groupLabel;
+ private readonly string _schoolYear;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
@@ -51,11 +52,12 @@ public partial class ReportGradeDialogViewModel : ObservableObject
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading,
- Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel)
+ Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
{
_grades = grades; _exams = exams; _results = results; _students = students;
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
+ _schoolYear = schoolYear;
_selectedPeriod = PeriodOptions[0];
Recompute();
@@ -77,18 +79,24 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var period = SelectedPeriod.Period;
var periodTag = SelectedPeriod.Label;
+ var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
+ {
+ ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
+ ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
+ _ => SchoolYearPeriodKind.FullYear,
+ });
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
- var exams = _exams.GetByGroup(_groupId).Where(e => InPeriod(e.Date, period)).ToList();
+ var exams = _exams.GetByGroup(_groupId).Where(e => e.Date >= periodFrom && e.Date <= periodTo).ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
- var allGrades = _grades.GetByGroup(_groupId).Where(g => InPeriod(g.Date, period)).ToList();
+ var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList();
Rows.Clear();
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
- if (!StudentActiveInPeriod(membership, period)) continue;
+ if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
@@ -101,6 +109,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var examGrades = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
+ if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
if (result.Absent || result.Grade is null) continue;
@@ -108,10 +117,12 @@ public partial class ReportGradeDialogViewModel : ObservableObject
}
var participationGrades = allGrades
- .Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation)
+ .Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
+ && (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
.Select(g => (g.Value, g.Weight)).ToList();
var otherGrades = allGrades
- .Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation)
+ .Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation
+ && (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
.Select(g => (g.Value, g.Weight)).ToList();
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
@@ -168,23 +179,6 @@ public partial class ReportGradeDialogViewModel : ObservableObject
return sb.ToString();
}
- private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
- {
- ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
- ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
- _ => true,
- };
-
- private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
- {
- if (period == ParticipationPeriod.FullYear || m is null) return true;
- return m.Period switch
- {
- MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
- MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
- _ => true,
- };
- }
}
// ── Rundungsregel-Anzeige ─────────────────────────────────────────────────────
diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs
index 75d1bf5..f40727d 100644
--- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs
@@ -48,6 +48,7 @@ public partial class GradeOverviewTabView : UserControl
var dialogVm = new CollectiveGradeDialogViewModel(
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
_vm!.GroupId);
var dialog = new CollectiveGradeDialog { DataContext = dialogVm };
@@ -66,7 +67,7 @@ public partial class GradeOverviewTabView : UserControl
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
- _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel);
+ _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear);
var dialog = new ReportGradeDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
index 6ea03d0..b7cb966 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
@@ -20,9 +20,14 @@
+
-
+
@@ -52,8 +57,9 @@
CanUserResizeColumns="True"
Margin="0">
-
-
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
index f50167b..da7b3d5 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs
@@ -17,6 +17,7 @@ public partial class GroupDetailView : UserControl
if (DataContext is GroupDetailViewModel vm)
{
vm.OnAddStudent = ShowAddStudentDialog;
+ vm.OnWithdrawStudent = ShowWithdrawStudentDialog;
vm.OnAddExam = ShowAddExamDialog;
vm.OnEditExam = ShowEditExamDialog;
vm.OnDuplicateExam = ShowDuplicateExamDialog;
@@ -42,6 +43,20 @@ public partial class GroupDetailView : UserControl
return await dialog.ShowDialog(owner);
}
+ private async Task ShowWithdrawStudentDialog(StudentSummary student)
+ {
+ if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
+ var memberships = App.Services.GetRequiredService();
+ var membership = memberships.GetByStudentAndGroup(student.Id, vm.Group.Id);
+ if (membership is null) return false;
+
+ var dialogVm = new WithdrawStudentDialogViewModel(
+ memberships, membership, student.FullName, vm.Group.Name);
+ var dialog = new WithdrawStudentDialog { DataContext = dialogVm };
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ return owner is not null && await dialog.ShowDialog(owner);
+ }
+
private Task ShowAddExamDialog(Guid groupId) =>
ShowExamDialog(groupId, editingExam: null, duplicateSource: null);
diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs
index 6cedee7..843c17d 100644
--- a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs
@@ -290,6 +290,7 @@ public partial class ParticipationTabView : UserControl
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
diff --git a/LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml b/LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml
new file mode 100644
index 0000000..1ac8abe
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml.cs
new file mode 100644
index 0000000..eadbd19
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.ViewModels.Groups;
+
+namespace LehrerApp.Desktop.Views.Groups;
+
+public partial class WithdrawStudentDialog : Window
+{
+ public WithdrawStudentDialog() => InitializeComponent();
+
+ private void OnSave(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not WithdrawStudentDialogViewModel vm) return;
+ vm.SaveCommand.Execute(null);
+ if (vm.WasSaved) Close(true);
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/LehrerApp.Tests/GroupMembershipServiceTests.cs b/LehrerApp.Tests/GroupMembershipServiceTests.cs
new file mode 100644
index 0000000..4796e72
--- /dev/null
+++ b/LehrerApp.Tests/GroupMembershipServiceTests.cs
@@ -0,0 +1,54 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public class GroupMembershipServiceTests
+{
+ [Fact]
+ public void IsActiveOn_BeruecksichtigtEinUndAustrittAuchBeiGanzemJahr()
+ {
+ var membership = new GroupMembership
+ {
+ Period = MembershipPeriod.FullYear,
+ JoinedAt = new DateOnly(2025, 9, 1),
+ LeftAt = new DateOnly(2025, 10, 15),
+ };
+
+ Assert.False(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 8, 31)));
+ Assert.True(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 9, 1)));
+ Assert.True(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 10, 15)));
+ Assert.False(GroupMembershipService.IsActiveOn(membership, new DateOnly(2025, 10, 16)));
+ }
+
+ [Theory]
+ [InlineData(2025, 12, 1, true)]
+ [InlineData(2026, 1, 31, true)]
+ [InlineData(2026, 2, 1, false)]
+ public void IsActiveOn_BeruecksichtigtHalbjahresmodus(int year, int month, int day, bool expected)
+ {
+ var membership = new GroupMembership { Period = MembershipPeriod.H1Only };
+
+ Assert.Equal(expected,
+ GroupMembershipService.IsActiveOn(membership, new DateOnly(year, month, day)));
+ }
+
+ [Fact]
+ public void Overlaps_FindetTeilweiseZugehoerigkeitImHalbjahr()
+ {
+ var membership = new GroupMembership
+ {
+ Period = MembershipPeriod.Custom,
+ JoinedAt = new DateOnly(2025, 12, 15),
+ LeftAt = new DateOnly(2026, 3, 10),
+ };
+ var h1 = GroupMembershipService.SchoolYearPeriod("2025/26", SchoolYearPeriodKind.H1);
+ var h2 = GroupMembershipService.SchoolYearPeriod("2025/26", SchoolYearPeriodKind.H2);
+
+ Assert.True(GroupMembershipService.Overlaps(membership, h1.From, h1.To));
+ Assert.True(GroupMembershipService.Overlaps(membership, h2.From, h2.To));
+ Assert.False(GroupMembershipService.Overlaps(membership,
+ new DateOnly(2026, 4, 1), new DateOnly(2026, 7, 31)));
+ }
+}
diff --git a/TODO.md b/TODO.md
index db035f1..a55d1d2 100644
--- a/TODO.md
+++ b/TODO.md
@@ -862,9 +862,17 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt.
- [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft,
neues `SchoolYear`, neue `GroupMembership`-Einträge.
-- [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`GroupMembership.LeftAt` setzen statt löschen).
-- [ ] **7.2.4** Umgang mit `MembershipPeriod.Custom` in allen Auswertungen prüfen
- (Schüler zählt nur im belegten Zeitraum).
+- [x] **7.2.3** Schüler aus einer Gruppe austragen, ohne die Mitgliedschaft zu löschen — eigener
+ Dialog mit `CalendarDatePicker`, aktuellem Datum als Vorbelegung und frei wählbarem
+ Austrittsdatum. Setzt `GroupMembership.LeftAt`; Eintrittsdatum, Niveau und historische
+ Leistungen bleiben erhalten. Ehemalige Schüler können in der Gruppenansicht eingeblendet
+ und ihre Austragung zurückgenommen werden. Ein bereits gesetztes Austrittsdatum lässt sich
+ über denselben Dialog ändern.
+- [x] **7.2.4** `JoinedAt`/`LeftAt` und `MembershipPeriod` werden über den gemeinsamen
+ `GroupMembershipService` in allen gruppenbezogenen Auswertungen berücksichtigt: Mitarbeit,
+ Mitarbeits-Assistent/-Aggregation, Klausur-Punkteeingabe, Sammelnoten, Notenübersicht und
+ Zeugnisnoten. Ein Schüler zählt nur an Tagen bzw. in Zeiträumen, in denen die Mitgliedschaft
+ tatsächlich aktiv war; der Austrittstag selbst zählt noch als zugehörig.
- [ ] **7.2.5** Archivansicht abgeschlossener Schuljahre (schreibgeschützt).
### 7.3 Import