From da8d2bb1da3d226dd92cabbb1453337f4b4cbe2d Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sat, 15 Aug 2026 23:38:42 +0200 Subject: [PATCH] =?UTF-8?q?Neue=20Funktion=20Sch=C3=BCler=20austragen=20st?= =?UTF-8?q?att=20aus=20Kurs=20l=C3=B6schen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/GroupMembershipService.cs | 64 ++++++++ LehrerApp.Desktop.Tests/Fakes.cs | 8 +- .../ReportGradeCalculationTests.cs | 3 +- .../WithdrawStudentDialogViewModelTests.cs | 39 +++++ .../Groups/ExamGradingViewModels.cs | 10 +- .../Groups/GradeOverviewViewModels.cs | 74 ++++++---- .../ViewModels/Groups/GroupViewModels.cs | 138 +++++++++++++++--- .../Groups/ParticipationGradeViewModels.cs | 10 +- .../Groups/ParticipationViewModels.cs | 12 +- .../Groups/ParticipationWizardViewModels.cs | 29 +++- .../Groups/ReportGradeViewModels.cs | 40 +++-- .../Groups/GradeOverviewTabView.axaml.cs | 3 +- .../Views/Groups/GroupDetailView.axaml | 12 +- .../Views/Groups/GroupDetailView.axaml.cs | 15 ++ .../Groups/ParticipationTabView.axaml.cs | 1 + .../Views/Groups/WithdrawStudentDialog.axaml | 42 ++++++ .../Groups/WithdrawStudentDialog.axaml.cs | 19 +++ .../GroupMembershipServiceTests.cs | 54 +++++++ TODO.md | 14 +- 19 files changed, 473 insertions(+), 114 deletions(-) create mode 100644 LehrerApp.Core/Services/GroupMembershipService.cs create mode 100644 LehrerApp.Desktop.Tests/WithdrawStudentDialogViewModelTests.cs create mode 100644 LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/WithdrawStudentDialog.axaml.cs create mode 100644 LehrerApp.Tests/GroupMembershipServiceTests.cs 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 @@ +