Neue Funktion Schüler austragen statt aus Kurs löschen.

This commit is contained in:
2026-08-15 23:38:42 +02:00
parent 52bb9c2c2f
commit da8d2bb1da
19 changed files with 473 additions and 114 deletions
@@ -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<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
@@ -185,6 +187,7 @@ public partial class GroupDetailViewModel : ObservableObject
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
public Func<Exam, Task<bool>>? OnEditExam { get; set; }
public Func<Exam, Task<bool>>? 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<StudentSummary>? 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