Kapitel 7 abgeschlossen.

This commit is contained in:
2026-08-16 00:29:01 +02:00
parent da8d2bb1da
commit f9e398b225
47 changed files with 2636 additions and 107 deletions
@@ -37,6 +37,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
[ObservableProperty] private bool _showAsPoints = true;
[ObservableProperty] private GradeOverviewRow? _selectedRow;
[ObservableProperty] private int _rebuildColumnsSignal;
[ObservableProperty] private bool _isReadOnly;
public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15;
@@ -64,13 +65,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
}
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType,
string groupLabel, string schoolYear)
string groupLabel, string schoolYear, bool isReadOnly = false)
{
_groupId = groupId;
_gradingSystem = gradingSystem;
_groupType = groupType;
_groupLabel = groupLabel;
_schoolYear = schoolYear;
IsReadOnly = isReadOnly;
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
OnPropertyChanged(nameof(CanTogglePointsView));
Recompute();
@@ -0,0 +1,164 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class GroupRolloverDialogViewModel : ObservableObject
{
private readonly LearningGroup _source;
private readonly GroupRolloverService _rollover;
private readonly SchoolYearService _schoolYears;
[ObservableProperty] private string _name;
[ObservableProperty] private string _targetSchoolYear;
[ObservableProperty] private int _gradeLevel;
[ObservableProperty] private bool _copyGradingScheme;
[ObservableProperty] private bool _archiveSource = true;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _schoolYearError = "";
[ObservableProperty] private string _selectionError = "";
[ObservableProperty] private string _generalError = "";
public string SourceSummary => $"{_source.Name} · {_source.SchoolYear} · Stufe {_source.GradeLevel}";
public bool HasGradingScheme { get; }
public int SelectedCount => Students.Count(s => s.IsSelected);
public string SelectedCountText => $"{SelectedCount} von {Students.Count} Schülern ausgewählt";
public ObservableCollection<GroupRolloverStudentItem> Students { get; } = [];
public LearningGroup? Result { get; private set; }
public GroupRolloverDialogViewModel(LearningGroup source,
IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository gradingSchemes, SchoolYearService schoolYears,
GroupRolloverService rollover)
{
_source = source;
_rollover = rollover;
_schoolYears = schoolYears;
_name = source.Name;
_targetSchoolYear = NextSchoolYear(source.SchoolYear, schoolYears);
_gradeLevel = Math.Min(13, source.GradeLevel + 1);
HasGradingScheme = gradingSchemes.GetByGroup(source.Id) is not null;
_copyGradingScheme = HasGradingScheme;
var sourceEnd = schoolYears.SchoolYearEnd(source.SchoolYear);
foreach (var membership in memberships.GetByGroup(source.Id)
.Select(m => (Membership: m, Student: students.GetById(m.StudentId)))
.Where(x => x.Student is not null)
.OrderBy(x => x.Student!.LastName).ThenBy(x => x.Student!.FirstName))
{
var canSelect = membership.Student!.IsActive;
var isPreselected = canSelect
&& GroupMembershipService.IsActiveOn(membership.Membership, sourceEnd);
var item = new GroupRolloverStudentItem(
membership.Student, membership.Membership, isPreselected, canSelect);
item.PropertyChanged += (_, e) =>
{
if (e.PropertyName != nameof(GroupRolloverStudentItem.IsSelected)) return;
SelectionError = "";
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(SelectedCountText));
};
Students.Add(item);
}
}
[RelayCommand]
private void Save()
{
NameError = "";
SchoolYearError = "";
SelectionError = "";
GeneralError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(Name))
{
NameError = "Gruppenname erforderlich.";
valid = false;
}
try
{
var targetText = TargetSchoolYear.Trim();
var targetStart = _schoolYears.SchoolYearStart(targetText);
if (targetText != _schoolYears.FormatSchoolYear(targetStart.Year))
{
SchoolYearError = "Format JJJJ/JJ, z.B. 2027/28.";
valid = false;
}
else if (targetStart <= _schoolYears.SchoolYearStart(_source.SchoolYear))
{
SchoolYearError = "Das Zielschuljahr muss nach dem bisherigen liegen.";
valid = false;
}
}
catch (Exception ex) when (ex is FormatException or ArgumentOutOfRangeException or IndexOutOfRangeException)
{
SchoolYearError = "Format JJJJ/JJ, z.B. 2027/28.";
valid = false;
}
if (SelectedCount == 0)
{
SelectionError = "Bitte mindestens einen Schüler auswählen.";
valid = false;
}
if (!valid) return;
try
{
Result = _rollover.RollOver(_source, new GroupRolloverRequest(
Name, TargetSchoolYear.Trim(), GradeLevel,
Students.Where(s => s.IsSelected).Select(s => s.MembershipId).ToList(),
CopyGradingScheme, ArchiveSource));
}
catch (Exception ex) when (ex is InvalidOperationException or FormatException or ArgumentOutOfRangeException)
{
GeneralError = ex.Message;
}
}
private static string NextSchoolYear(string source, SchoolYearService schoolYears)
{
var start = schoolYears.SchoolYearStart(source);
return schoolYears.FormatSchoolYear(start.Year + 1);
}
}
public partial class GroupRolloverStudentItem : ObservableObject
{
[ObservableProperty] private bool _isSelected;
public Guid MembershipId { get; }
public string FullName { get; }
public string NiveauText { get; }
public string MembershipStatus { get; }
public bool CanSelect { get; }
public GroupRolloverStudentItem(Student student, GroupMembership membership,
bool isSelected, bool canSelect)
{
MembershipId = membership.Id;
FullName = student.FullName;
NiveauText = membership.Niveau?.ToString() ?? "";
CanSelect = canSelect;
_isSelected = isSelected;
MembershipStatus = BuildStatus(student, membership, isSelected);
}
partial void OnIsSelectedChanged(bool value)
{
if (!CanSelect && value) IsSelected = false;
}
private static string BuildStatus(Student student, GroupMembership membership, bool isPreselected)
{
if (!student.IsActive) return "Schüler inaktiv · nicht übernehmbar";
if (isPreselected) return "Bis Schuljahresende aktiv · vorausgewählt";
if (membership.LeftAt is { } leftAt) return $"Ausgetreten am {leftAt:dd.MM.yyyy} · nicht vorausgewählt";
if (membership.Period == MembershipPeriod.H1Only) return "Nur 1. Halbjahr · nicht vorausgewählt";
return "Am Schuljahresende nicht aktiv · nicht vorausgewählt";
}
}
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -17,6 +18,7 @@ public partial class GroupListViewModel : ObservableObject
public Action<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; }
public Func<Guid, Task>? OnEditGroup { get; set; }
public Func<Guid, Task<Guid?>>? OnRollOverGroup { get; set; }
public Func<GroupListItem, Task<bool>>? OnConfirmDelete { get; set; }
[ObservableProperty] private string _selectedSchoolYear = "";
@@ -53,6 +55,7 @@ public partial class GroupListViewModel : ObservableObject
OnPropertyChanged(nameof(SelectedGroupSubtitle));
NavigateToSectionCommand.NotifyCanExecuteChanged();
EditGroupCommand.NotifyCanExecuteChanged();
RollOverGroupCommand.NotifyCanExecuteChanged();
ToggleArchiveCommand.NotifyCanExecuteChanged();
DeleteGroupCommand.NotifyCanExecuteChanged();
}
@@ -85,7 +88,7 @@ public partial class GroupListViewModel : ObservableObject
}
[RelayCommand] private void Refresh() => LoadGroups();
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
private async Task EditGroup()
{
if (SelectedGroup is null || OnEditGroup is null) return;
@@ -95,6 +98,20 @@ public partial class GroupListViewModel : ObservableObject
SelectedGroup = Groups.FirstOrDefault(g => g.Id == id);
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private async Task RollOverGroup()
{
if (SelectedGroup is null || OnRollOverGroup is null) return;
var targetId = await OnRollOverGroup(SelectedGroup.Id);
if (targetId is null) return;
var target = _groups.GetById(targetId.Value);
if (target is null) return;
if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear);
SelectedSchoolYear = target.SchoolYear;
LoadGroups();
SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id);
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
private void ToggleArchive()
{
@@ -106,7 +123,7 @@ public partial class GroupListViewModel : ObservableObject
LoadGroups();
}
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
private async Task DeleteGroup()
{
if (SelectedGroup is null || OnConfirmDelete is null) return;
@@ -124,6 +141,7 @@ public partial class GroupListViewModel : ObservableObject
}
private bool HasSelectedGroup() => SelectedGroup is not null;
private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true;
}
public class GroupListItem
@@ -175,9 +193,17 @@ public partial class GroupDetailViewModel : ObservableObject
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
public bool IsReadOnly => Group is { IsActive: false };
public bool IsEditable => Group is { IsActive: true };
public string SubjectName { get; private set; } = "";
partial void OnGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(IsDifferentiated));
partial void OnGroupChanged(LearningGroup? value)
{
OnPropertyChanged(nameof(IsDifferentiated));
OnPropertyChanged(nameof(IsReadOnly));
OnPropertyChanged(nameof(IsEditable));
NotifyWriteCommands();
}
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
public ObservableCollection<StudentSummary> Students { get; } = [];
@@ -194,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject
public Func<ExamSummary, Task<bool>>? OnConfirmDeleteExam { get; set; }
public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Func<Task<bool>>? OnConfirmReactivate { get; set; }
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects,
@@ -221,9 +248,9 @@ public partial class GroupDetailViewModel : ObservableObject
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015")}";
LoadStudents();
ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear);
PlanningTab.Initialize(Group.Id);
ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
PlanningTab.Initialize(Group.Id, IsReadOnly);
}
private void ReloadExams()
@@ -247,7 +274,11 @@ public partial class GroupDetailViewModel : ObservableObject
{
memberships.TryGetValue(s.Id, out var membership);
if (membership is null) continue;
var summary = new StudentSummary(s, membership, today) { OnChanged = SaveStudentNiveau };
var summary = new StudentSummary(s, membership, today)
{
OnChanged = SaveStudentNiveau,
CanEdit = IsEditable,
};
if (summary.IsFormer && !ShowFormerStudents) continue;
Students.Add(summary);
}
@@ -255,14 +286,14 @@ public partial class GroupDetailViewModel : ObservableObject
private void SaveStudentNiveau(StudentSummary summary)
{
if (Group is null) return;
if (!IsEditable || Group is null) return;
var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
if (membership is null) return;
membership.Niveau = summary.Niveau;
_memberships.Save(membership);
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
private async Task AddStudent()
{
if (OnAddStudent is null) return;
@@ -274,7 +305,7 @@ public partial class GroupDetailViewModel : ObservableObject
}
}
[RelayCommand(CanExecute = nameof(HasSelectedStudent))]
[RelayCommand(CanExecute = nameof(CanEditSelectedStudent))]
private async Task WithdrawStudent()
{
if (SelectedStudent is null || OnWithdrawStudent is null) return;
@@ -284,7 +315,7 @@ public partial class GroupDetailViewModel : ObservableObject
ParticipationTab.RefreshCurrentGrid();
}
[RelayCommand(CanExecute = nameof(CanReinstateSelectedStudent))]
[RelayCommand(CanExecute = nameof(CanEditAndReinstateSelectedStudent))]
private void ReinstateStudent()
{
if (Group is null || SelectedStudent is null) return;
@@ -303,10 +334,10 @@ public partial class GroupDetailViewModel : ObservableObject
ReinstateStudentCommand.NotifyCanExecuteChanged();
}
private bool HasSelectedStudent() => SelectedStudent is not null;
private bool CanReinstateSelectedStudent() => SelectedStudent?.HasExitDate == true;
private bool CanEditSelectedStudent() => IsEditable && SelectedStudent is not null;
private bool CanEditAndReinstateSelectedStudent() => IsEditable && SelectedStudent?.HasExitDate == true;
[RelayCommand]
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
private async Task AddExam()
{
if (Group is null || OnAddExam is null) return;
@@ -314,7 +345,7 @@ public partial class GroupDetailViewModel : ObservableObject
if (saved) ReloadExams();
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task EditExam()
{
if (SelectedExam is null || OnEditExam is null) return;
@@ -329,7 +360,7 @@ public partial class GroupDetailViewModel : ObservableObject
}
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task GradeExam()
{
if (SelectedExam is null || OnGradeExam is null) return;
@@ -338,7 +369,7 @@ public partial class GroupDetailViewModel : ObservableObject
await OnGradeExam(exam);
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task EvaluateExam()
{
if (SelectedExam is null || OnEvaluateExam is null) return;
@@ -347,7 +378,7 @@ public partial class GroupDetailViewModel : ObservableObject
await OnEvaluateExam(exam);
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task DuplicateExam()
{
if (SelectedExam is null || OnDuplicateExam is null) return;
@@ -357,7 +388,7 @@ public partial class GroupDetailViewModel : ObservableObject
if (saved) ReloadExams();
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task DeleteExam()
{
if (SelectedExam is null || OnConfirmDeleteExam is null) return;
@@ -368,7 +399,7 @@ public partial class GroupDetailViewModel : ObservableObject
ReloadExams();
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private void AdvanceExamStatus()
{
if (SelectedExam is null) return;
@@ -381,7 +412,7 @@ public partial class GroupDetailViewModel : ObservableObject
});
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private void SetExamStatus(ExamStatus status)
{
if (SelectedExam is null) return;
@@ -409,7 +440,35 @@ public partial class GroupDetailViewModel : ObservableObject
SetExamStatusCommand.NotifyCanExecuteChanged();
}
private bool HasSelectedExam() => SelectedExam is not null;
private bool CanEditSelectedExam() => IsEditable && SelectedExam is not null;
private bool IsEditableGroup() => IsEditable;
[RelayCommand(CanExecute = nameof(IsReadOnlyGroup))]
private async Task ReactivateGroup()
{
if (Group is null || OnConfirmReactivate is null || !await OnConfirmReactivate()) return;
Group.IsActive = true;
_groups.Save(Group);
LoadGroup(Group.Id);
}
private bool IsReadOnlyGroup() => IsReadOnly;
private void NotifyWriteCommands()
{
AddStudentCommand.NotifyCanExecuteChanged();
WithdrawStudentCommand.NotifyCanExecuteChanged();
ReinstateStudentCommand.NotifyCanExecuteChanged();
AddExamCommand.NotifyCanExecuteChanged();
EditExamCommand.NotifyCanExecuteChanged();
GradeExamCommand.NotifyCanExecuteChanged();
EvaluateExamCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
SetExamStatusCommand.NotifyCanExecuteChanged();
ReactivateGroupCommand.NotifyCanExecuteChanged();
}
[RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); }
}
@@ -448,6 +507,9 @@ public partial class StudentSummary : ObservableObject
public bool HasExitDate { get; }
public string MembershipStatus { get; }
public string WithdrawActionLabel => HasExitDate ? "Austrittsdatum ändern" : "Austragen";
public string AvatarLabel { get; }
public string AvatarColor { get; }
public string AvatarTooltip { get; }
[ObservableProperty] private Niveau? _niveau;
@@ -458,11 +520,15 @@ public partial class StudentSummary : ObservableObject
}
public Action<StudentSummary>? OnChanged { get; set; }
public bool CanEdit { get; init; } = true;
public StudentSummary(Core.Models.Student s, GroupMembership? membership, DateOnly? today = null)
{
Id = s.Id;
FullName = s.FullName;
AvatarLabel = GenderAvatarDisplay.Label(s.Gender);
AvatarColor = GenderAvatarDisplay.Color(s.Gender);
AvatarTooltip = GenderAvatarDisplay.Tooltip(s.Gender);
PeriodLabel = membership is null ? "" : BuildPeriodLabel(membership);
HasExitDate = membership?.LeftAt.HasValue == true;
IsFormer = membership?.LeftAt is { } leftAt
@@ -37,6 +37,7 @@ public partial class ParticipationTabViewModel : ObservableObject
[ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _studentCompetencyRatingsVisible;
[ObservableProperty] private int _rebuildColumnsSignal;
[ObservableProperty] private bool _isReadOnly;
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
public List<string> ActiveCompetencyCodes { get; private set; } = [];
@@ -67,10 +68,11 @@ public partial class ParticipationTabViewModel : ObservableObject
_competencyDomains = competencyDomains;
}
public void Initialize(Guid groupId, string schoolYear)
public void Initialize(Guid groupId, string schoolYear, bool isReadOnly = false)
{
_groupId = groupId;
_schoolYear = schoolYear;
IsReadOnly = isReadOnly;
var group = _groups.GetById(groupId);
_subjectId = group?.SubjectId;
@@ -160,6 +162,7 @@ public partial class ParticipationTabViewModel : ObservableObject
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value)
{
if (IsReadOnly) return;
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry
{
@@ -187,6 +190,7 @@ public partial class ParticipationTabViewModel : ObservableObject
private void SaveHomework(Guid sessionId, Guid studentId, HomeworkStatus? value)
{
if (IsReadOnly) return;
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Homework = value;
@@ -196,6 +200,7 @@ public partial class ParticipationTabViewModel : ObservableObject
private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value)
{
if (IsReadOnly) return;
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Attendance = value;
@@ -39,6 +39,7 @@ public partial class PlanningTabViewModel : ObservableObject
[ObservableProperty] private UnitSummary? _selectedUnit;
[ObservableProperty] private LessonSummary? _selectedLesson;
[ObservableProperty] private bool _isReadOnly;
// Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen
// Gruppenwechsel/Laden kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal
@@ -74,9 +75,10 @@ public partial class PlanningTabViewModel : ObservableObject
_subjects = subjects; _competencyDomains = competencyDomains;
}
public void Initialize(Guid groupId)
public void Initialize(Guid groupId, bool isReadOnly = false)
{
_groupId = groupId;
IsReadOnly = isReadOnly;
var group = _groups.GetById(groupId);
SubjectId = group?.SubjectId;
GradeLevel = group?.GradeLevel ?? 0;