Neue Funktion Schüler austragen statt aus Kurs löschen.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
public enum SchoolYearPeriodKind { FullYear, H1, H2 }
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsame Regeln für zeitlich begrenzte Gruppenzugehörigkeiten.
|
||||
/// Eintritt und Austritt gelten unabhängig vom gewählten Halbjahresmodus.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -21,8 +21,12 @@ public class FakeMemberships(List<GroupMembership> all) : IGroupMembershipReposi
|
||||
public List<GroupMembership> 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<ParticipationSession> all) : IParticipationSessionRepository
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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<string>();
|
||||
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<Student> _students;
|
||||
private readonly Guid _groupId;
|
||||
|
||||
[ObservableProperty] private GradeCategory _category = GradeCategory.Other;
|
||||
@@ -422,11 +421,36 @@ public partial class CollectiveGradeDialogViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<CollectiveGradeStudentRow> 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ─────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly Dictionary<Guid, GroupMembership> _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<double>();
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,6 +48,7 @@ public partial class GradeOverviewTabView : UserControl
|
||||
var dialogVm = new CollectiveGradeDialogViewModel(
|
||||
App.Services.GetRequiredService<IGradeRepository>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
_vm!.GroupId);
|
||||
|
||||
var dialog = new CollectiveGradeDialog { DataContext = dialogVm };
|
||||
@@ -66,7 +67,7 @@ public partial class GradeOverviewTabView : UserControl
|
||||
App.Services.GetRequiredService<IGradingSchemeRepository>(),
|
||||
App.Services.GetRequiredService<IReportGradeRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
_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;
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
<Run Text="{Binding StudentCount}"/>
|
||||
<Run Text=" Schüler"/>
|
||||
</TextBlock>
|
||||
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}"/>
|
||||
<Button Content="− Austragen" Command="{Binding RemoveStudentCommand}"
|
||||
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
||||
Command="{Binding WithdrawStudentCommand}"
|
||||
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
|
||||
IsVisible="{Binding SelectedStudent.HasExitDate}"/>
|
||||
<Button Content="+ Klausur" Command="{Binding AddExamCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -52,8 +57,9 @@
|
||||
CanUserResizeColumns="True"
|
||||
Margin="0">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
|
||||
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="90"/>
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
|
||||
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="190"/>
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding MembershipStatus}" Width="170"/>
|
||||
<DataGridTemplateColumn Header="Niveau" Width="130"
|
||||
IsVisible="{Binding IsDifferentiated}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
|
||||
@@ -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<bool>(owner);
|
||||
}
|
||||
|
||||
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
|
||||
{
|
||||
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
|
||||
var memberships = App.Services.GetRequiredService<IGroupMembershipRepository>();
|
||||
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<bool>(owner);
|
||||
}
|
||||
|
||||
private Task<bool> ShowAddExamDialog(Guid groupId) =>
|
||||
ShowExamDialog(groupId, editingExam: null, duplicateSource: null);
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ public partial class ParticipationTabView : UserControl
|
||||
App.Services.GetRequiredService<IParticipationAspectRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSectionRepository>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<IGradeRepository>(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.WithdrawStudentDialog"
|
||||
x:DataType="vm:WithdrawStudentDialogViewModel"
|
||||
Title="Schüler austragen"
|
||||
Width="440" Height="330"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="14">
|
||||
<TextBlock Text="Schüler aus Lerngruppe austragen" Classes="dialogtitle"/>
|
||||
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="6" Padding="12">
|
||||
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Schüler" Opacity="0.65"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding StudentName}" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Lerngruppe" Opacity="0.65"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding GroupName}" FontWeight="SemiBold"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Austrittsdatum" FontSize="12" Opacity="0.7"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding SelectedDate}"
|
||||
DisplayDateStart="{Binding EarliestDate}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Die bisherige Gruppenzugehörigkeit und alle zugehörigen Leistungen bleiben erhalten."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,18,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Austragen" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user