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
@@ -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);
}