using CommunityToolkit.Mvvm.ComponentModel; 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; // ── Gruppenliste ────────────────────────────────────────────────────────────── public partial class GroupListViewModel : ObservableObject { private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; public Action? OnNavigateToDetail { get; set; } public Func? OnAddGroup { get; set; } public Func? OnEditGroup { get; set; } public Func>? OnRollOverGroup { get; set; } public Func>? OnConfirmDelete { get; set; } [ObservableProperty] private string _selectedSchoolYear = ""; [ObservableProperty] private string _searchText = ""; [ObservableProperty] private GroupListItem? _selectedGroup; [ObservableProperty] private bool _showArchived; public string SelectedGroupDisplayName => SelectedGroup?.DisplayName ?? ""; public string SelectedGroupSubtitle => SelectedGroup?.Subtitle ?? ""; public string ListSummary => ShowArchived ? $"{Groups.Count} archivierte Gruppen · {SelectedSchoolYear}" : $"{Groups.Count} aktive Gruppen · {SelectedSchoolYear}"; public bool HasNoGroups => Groups.Count == 0; public string EmptyListMessage => ShowArchived ? "Keine archivierten Lerngruppen in diesem Schuljahr." : "Noch keine aktiven Lerngruppen in diesem Schuljahr."; public ObservableCollection SchoolYears { get; } = []; public ObservableCollection Groups { get; } = []; public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy) { _groups = groups; _subjects = subjects; foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y); SelectedSchoolYear = sy.CurrentSchoolYear(); } partial void OnSelectedSchoolYearChanged(string value) => LoadGroups(); partial void OnSearchTextChanged(string value) => LoadGroups(); partial void OnShowArchivedChanged(bool value) => LoadGroups(); partial void OnSelectedGroupChanged(GroupListItem? value) { OnPropertyChanged(nameof(SelectedGroupDisplayName)); OnPropertyChanged(nameof(SelectedGroupSubtitle)); NavigateToSectionCommand.NotifyCanExecuteChanged(); EditGroupCommand.NotifyCanExecuteChanged(); RollOverGroupCommand.NotifyCanExecuteChanged(); ToggleArchiveCommand.NotifyCanExecuteChanged(); DeleteGroupCommand.NotifyCanExecuteChanged(); } public void LoadGroups() { var selectedId = SelectedGroup?.Id; Groups.Clear(); var all = _groups.GetBySchoolYear(SelectedSchoolYear, includeInactive: ShowArchived) .Where(g => g.IsActive != ShowArchived); var subjectNames = _subjects.GetAll().ToDictionary(s => s.Id, s => s.Name); var filtered = string.IsNullOrWhiteSpace(SearchText) ? all : all.Where(g => g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || (g.SubjectId is Guid id && subjectNames.GetValueOrDefault(id, "") .Contains(SearchText, StringComparison.OrdinalIgnoreCase))); foreach (var g in filtered.OrderBy(g => g.Name)) Groups.Add(new GroupListItem(g, g.SubjectId is Guid id ? subjectNames.GetValueOrDefault(id, "") : "")); SelectedGroup = Groups.FirstOrDefault(g => g.Id == selectedId); OnPropertyChanged(nameof(ListSummary)); OnPropertyChanged(nameof(HasNoGroups)); OnPropertyChanged(nameof(EmptyListMessage)); } [RelayCommand] private async Task AddGroup() { if (OnAddGroup is null) return; await OnAddGroup(); LoadGroups(); } [RelayCommand] private void Refresh() => LoadGroups(); [RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] private async Task EditGroup() { if (SelectedGroup is null || OnEditGroup is null) return; var id = SelectedGroup.Id; await OnEditGroup(id); LoadGroups(); 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() { if (SelectedGroup is null) return; var group = _groups.GetById(SelectedGroup.Id); if (group is null) return; group.IsActive = !group.IsActive; _groups.Save(group); LoadGroups(); } [RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] private async Task DeleteGroup() { if (SelectedGroup is null || OnConfirmDelete is null) return; var selected = SelectedGroup; if (!await OnConfirmDelete(selected)) return; _groups.Delete(selected.Id); LoadGroups(); } [RelayCommand(CanExecute = nameof(HasSelectedGroup))] private void NavigateToSection(string? tabIndex) { if (SelectedGroup is null || !int.TryParse(tabIndex, out var tab)) return; OnNavigateToDetail?.Invoke(SelectedGroup.Id, tab); } private bool HasSelectedGroup() => SelectedGroup is not null; private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true; } public class GroupListItem { public Guid Id { get; } public string Name { get; } public string Subject { get; } public string DisplayName { get; } public string TypeLabel { get; } public string GradingLabel { get; } public string Subtitle { get; } public bool IsActive { get; } public string ArchiveActionLabel => IsActive ? "Archivieren" : "Wieder aktivieren"; public GroupListItem(LearningGroup g, string subjectName) { Id = g.Id; IsActive = g.IsActive; Name = g.Name; Subject = subjectName; TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs"; GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15"; DisplayName = string.IsNullOrEmpty(subjectName) ? g.Name : $"{g.Name} · {subjectName}"; Subtitle = $"{TypeLabel} · Stufe {g.GradeLevel} · Noten {GradingLabel} · {g.SchoolYear}"; } } // ── Gruppendetail ───────────────────────────────────────────────────────────── public partial class GroupDetailViewModel : ObservableObject { private readonly IGroupRepository _groups; private readonly IStudentRepository _students; private readonly IGroupMembershipRepository _memberships; private readonly ISubjectRepository _subjects; private readonly IExamRepository _exams; private readonly IGradeRepository _grades; private readonly IWorkTaskRepository _tasks; [ObservableProperty] private LearningGroup? _group; [ObservableProperty] private string _groupTitle = ""; [ObservableProperty] private string _groupSubtitle = ""; [ObservableProperty] private int _studentCount; [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 // 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)); OnPropertyChanged(nameof(IsReadOnly)); OnPropertyChanged(nameof(IsEditable)); NotifyWriteCommands(); } partial void OnShowFormerStudentsChanged(bool value) => LoadStudents(); partial void OnActiveTabIndexChanged(int value) { if (value == 7) CompetencyOverviewTab.Refresh(); } public ObservableCollection Students { get; } = []; public ObservableCollection Exams { get; } = []; public ParticipationTabViewModel ParticipationTab { get; } public GradeOverviewTabViewModel GradeOverviewTab { get; } public PlanningTabViewModel PlanningTab { get; } public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; } public SeatingPlanTabViewModel SeatingPlanTab { get; } public Func>? OnAddStudent { get; set; } public Func>? OnWithdrawStudent { get; set; } public Func>? OnAddExam { get; set; } public Func>? OnEditExam { get; set; } public Func>? OnDuplicateExam { get; set; } public Func>? OnConfirmDeleteExam { get; set; } public Func? OnGradeExam { get; set; } public Func? OnEvaluateExam { get; set; } public Func>? OnConfirmReactivate { get; set; } public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IGroupMembershipRepository memberships, ISubjectRepository subjects, IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab, SeatingPlanTabViewModel seatingPlanTab) { _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _exams = exams; _grades = grades; _tasks = tasks; ParticipationTab = participationTab; GradeOverviewTab = gradeOverviewTab; PlanningTab = planningTab; CompetencyOverviewTab = competencyOverviewTab; SeatingPlanTab = seatingPlanTab; } public void LoadGroup(Guid id) { Group = _groups.GetById(id); if (Group is null) return; SubjectName = Group.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : ""; GroupTitle = Group.Name; GroupSubtitle = $"{Group.SchoolYear} · " + $"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " + $"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15")}"; LoadStudents(); ReloadExams(); ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly); GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly); PlanningTab.Initialize(Group.Id, IsReadOnly); CompetencyOverviewTab.Initialize(Group); SeatingPlanTab.Initialize(Group.Id, IsReadOnly); } private void ReloadExams() { if (Group is null) return; var selectedId = SelectedExam?.Id; Exams.Clear(); foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e)); SelectedExam = Exams.FirstOrDefault(e => e.Id == selectedId); CompetencyOverviewTab.Refresh(); } public void LoadStudents() { if (Group is null) return; Students.Clear(); var enrolled = _students.GetByGroup(Group.Id); var memberships = _memberships.GetByGroup(Group.Id).ToDictionary(e => e.StudentId); 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); if (membership is null) continue; var summary = new StudentSummary(s, membership, today) { OnChanged = SaveStudentNiveau, CanEdit = IsEditable, }; if (summary.IsFormer && !ShowFormerStudents) continue; Students.Add(summary); } } private void SaveStudentNiveau(StudentSummary summary) { 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(CanExecute = nameof(IsEditableGroup))] private async Task AddStudent() { if (OnAddStudent is null) return; var confirmed = await OnAddStudent(); if (confirmed) { LoadStudents(); ParticipationTab.RefreshCurrentGrid(); } } [RelayCommand(CanExecute = nameof(CanEditSelectedStudent))] private async Task WithdrawStudent() { if (SelectedStudent is null || OnWithdrawStudent is null) return; if (!await OnWithdrawStudent(SelectedStudent)) return; LoadStudents(); SelectedStudent = null; ParticipationTab.RefreshCurrentGrid(); } [RelayCommand(CanExecute = nameof(CanEditAndReinstateSelectedStudent))] 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 CanEditSelectedStudent() => IsEditable && SelectedStudent is not null; private bool CanEditAndReinstateSelectedStudent() => IsEditable && SelectedStudent?.HasExitDate == true; [RelayCommand(CanExecute = nameof(IsEditableGroup))] private async Task AddExam() { if (Group is null || OnAddExam is null) return; var saved = await OnAddExam(Group.Id); if (saved) ReloadExams(); } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task EditExam() { if (SelectedExam is null || OnEditExam is null) return; var exam = _exams.GetById(SelectedExam.Id); if (exam is null) return; var id = exam.Id; var saved = await OnEditExam(exam); if (saved) { ReloadExams(); SelectedExam = Exams.FirstOrDefault(e => e.Id == id); } } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task GradeExam() { if (SelectedExam is null || OnGradeExam is null) return; var exam = _exams.GetById(SelectedExam.Id); if (exam is null) return; await OnGradeExam(exam); CompetencyOverviewTab.Refresh(); } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task EvaluateExam() { if (SelectedExam is null || OnEvaluateExam is null) return; var exam = _exams.GetById(SelectedExam.Id); if (exam is null) return; await OnEvaluateExam(exam); } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task DuplicateExam() { if (SelectedExam is null || OnDuplicateExam is null) return; var exam = _exams.GetById(SelectedExam.Id); if (exam is null) return; var saved = await OnDuplicateExam(exam); if (saved) ReloadExams(); } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task DeleteExam() { if (SelectedExam is null || OnConfirmDeleteExam is null) return; var selected = SelectedExam; if (!await OnConfirmDeleteExam(selected)) return; _exams.Delete(selected.Id); SelectedExam = null; ReloadExams(); } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private void AdvanceExamStatus() { if (SelectedExam is null) return; SetExamStatus(SelectedExam.Status switch { ExamStatus.Planned => ExamStatus.Conducted, ExamStatus.Conducted => ExamStatus.Graded, ExamStatus.Graded => ExamStatus.Returned, _ => SelectedExam.Status, }); } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private void SetExamStatus(ExamStatus status) { if (SelectedExam is null) return; var exam = _exams.GetById(SelectedExam.Id); if (exam is null) return; var previousStatus = exam.Status; exam.Status = status; if (status == ExamStatus.Returned) exam.ReturnedAt ??= DateOnly.FromDateTime(DateTime.Today); else exam.ReturnedAt = null; _exams.Save(exam); // 6.1.5: beim erstmaligen Erreichen von "Durchgeführt" automatisch eine Korrektur-Aufgabe anlegen. if (status == ExamStatus.Conducted && previousStatus == ExamStatus.Planned) _tasks.Save(new WorkTask { Title = $"Klausur korrigieren: {exam.Title}", Category = TaskCategory.Correction, GroupId = exam.GroupId, DueDate = exam.Date.AddDays(14), Status = WorkTaskStatus.Open, }); var id = exam.Id; ReloadExams(); SelectedExam = Exams.FirstOrDefault(e => e.Id == id); } partial void OnSelectedExamChanged(ExamSummary? value) { EditExamCommand.NotifyCanExecuteChanged(); GradeExamCommand.NotifyCanExecuteChanged(); EvaluateExamCommand.NotifyCanExecuteChanged(); DuplicateExamCommand.NotifyCanExecuteChanged(); DeleteExamCommand.NotifyCanExecuteChanged(); AdvanceExamStatusCommand.NotifyCanExecuteChanged(); SetExamStatusCommand.NotifyCanExecuteChanged(); } 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); } } // ── Niveau-Anzeige (Niveaudifferenzierung) ──────────────────────────────────── public static class NiveauDisplay { public static string[] Options { get; } = ["–", "E-Niveau", "G-Niveau", "Förderniveau"]; public static string ToName(Niveau? n) => n switch { Core.Models.Niveau.E => "E-Niveau", Core.Models.Niveau.G => "G-Niveau", Core.Models.Niveau.Foerder => "Förderniveau", _ => "–", }; public static Niveau? FromName(string? name) => name switch { "E-Niveau" => Core.Models.Niveau.E, "G-Niveau" => Core.Models.Niveau.G, "Förderniveau" => Core.Models.Niveau.Foerder, _ => null, }; } public partial class StudentSummary : ObservableObject { public static string[] NiveauOptions => NiveauDisplay.Options; 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"; public string AvatarLabel { get; } public string AvatarColor { get; } public string AvatarTooltip { get; } [ObservableProperty] private Niveau? _niveau; public string NiveauName { get => NiveauDisplay.ToName(Niveau); set => Niveau = NiveauDisplay.FromName(value); } public Action? 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 && 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; } partial void OnNiveauChanged(Niveau? value) { OnPropertyChanged(nameof(NiveauName)); OnChanged?.Invoke(this); } private static string BuildPeriodLabel(GroupMembership membership) { 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; } } public class ExamSummary { public Guid Id { get; } public string Title { get; } public string Date { get; } public ExamStatus Status { get; } public string StatusLabel { get; } public string StatusColorHex { get; } public string ReturnedAtDisplay { get; } public string NiveauLabel { get; } public ExamSummary(Core.Models.Exam e) { Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status; ReturnedAtDisplay = e.ReturnedAt?.ToString("dd.MM.yyyy") ?? ""; NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle"; StatusLabel = e.Status switch { ExamStatus.Planned => "Geplant", ExamStatus.Conducted => "Durchgeführt", ExamStatus.Graded => "Korrigiert", ExamStatus.Returned => "Zurückgegeben", _ => "", }; StatusColorHex = e.Status switch { ExamStatus.Planned => "#9E9E9E", ExamStatus.Conducted => "#FB8C00", ExamStatus.Graded => "#43A047", ExamStatus.Returned => "#1E88E5", _ => "#9E9E9E", }; } } // ── Dialog: Schüler zur Gruppe hinzufügen ───────────────────────────────────── public partial class AddStudentToGroupDialogViewModel : ObservableObject { private readonly IStudentRepository _students; private readonly IGroupMembershipRepository _memberships; private readonly Guid _groupId; [ObservableProperty] private string _searchText = ""; [ObservableProperty] private StudentPickerItem? _selectedStudent; [ObservableProperty] private string _validationMessage = ""; [ObservableProperty] private MembershipPeriod _period = MembershipPeriod.FullYear; [ObservableProperty] private string _joinedAtText = ""; [ObservableProperty] private string _leftAtText = ""; public bool IsFullYear { get => Period == MembershipPeriod.FullYear; set { if (value) Period = MembershipPeriod.FullYear; } } public bool IsH1Only { get => Period == MembershipPeriod.H1Only; set { if (value) Period = MembershipPeriod.H1Only; } } public bool IsH2Only { get => Period == MembershipPeriod.H2Only; set { if (value) Period = MembershipPeriod.H2Only; } } public bool IsCustom { get => Period == MembershipPeriod.Custom; set { if (value) Period = MembershipPeriod.Custom; } } public bool IsCustomPeriod => Period == MembershipPeriod.Custom; public ObservableCollection AvailableStudents { get; } = []; public GroupMembership? Result { get; private set; } public AddStudentToGroupDialogViewModel(IStudentRepository students, IGroupMembershipRepository memberships, Guid groupId) { _students = students; _memberships = memberships; _groupId = groupId; LoadAvailableStudents(); } partial void OnPeriodChanged(MembershipPeriod value) { OnPropertyChanged(nameof(IsFullYear)); OnPropertyChanged(nameof(IsH1Only)); OnPropertyChanged(nameof(IsH2Only)); OnPropertyChanged(nameof(IsCustom)); OnPropertyChanged(nameof(IsCustomPeriod)); } partial void OnSearchTextChanged(string value) => LoadAvailableStudents(); private void LoadAvailableStudents() { var alreadyAssigned = _memberships.GetByGroup(_groupId) .Select(e => e.StudentId).ToHashSet(); var all = _students.GetAll(); var available = all .Where(s => !alreadyAssigned.Contains(s.Id)) .Where(s => string.IsNullOrWhiteSpace(SearchText) || s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); AvailableStudents.Clear(); foreach (var s in available) AvailableStudents.Add(new StudentPickerItem(s)); } [RelayCommand] private void Save() { if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; } DateOnly? joinedAt = null, leftAt = null; if (Period == MembershipPeriod.Custom) { if (!string.IsNullOrWhiteSpace(JoinedAtText) && DateOnly.TryParseExact(JoinedAtText, "dd.MM.yyyy", null, System.Globalization.DateTimeStyles.None, out var j)) joinedAt = j; if (!string.IsNullOrWhiteSpace(LeftAtText) && DateOnly.TryParseExact(LeftAtText, "dd.MM.yyyy", null, System.Globalization.DateTimeStyles.None, out var l)) leftAt = l; } Result = new GroupMembership { StudentId = SelectedStudent.Id, GroupId = _groupId, Period = Period, JoinedAt = joinedAt, LeftAt = leftAt, }; _memberships.Save(Result); } } public class StudentPickerItem { public Guid Id { get; } public string FullName { get; } 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 { private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; private List _allSubjects = []; private LearningGroup? _editingGroup; public List TypeOptions { get; } = ["Klasse", "Kurs"]; [ObservableProperty] private string _selectedTypeName = "Kurs"; public bool IsKurs => SelectedTypeName == "Kurs"; public string NameLabel => IsKurs ? "Kursname *" : "Klassenname *"; public string NameHint => IsKurs ? "z.B. Q1, LK, GK" : "z.B. 10E, 5a, 11b"; partial void OnSelectedTypeNameChanged(string value) { OnPropertyChanged(nameof(IsKurs)); OnPropertyChanged(nameof(NameLabel)); OnPropertyChanged(nameof(NameHint)); } [ObservableProperty] private string _name = ""; [ObservableProperty] private string _subject = ""; [ObservableProperty] private int _gradeLevel = 10; [ObservableProperty] private string _selectedGradingName = "Noten 1–6"; [ObservableProperty] private string _selectedSchoolYear = ""; [ObservableProperty] private int? _hoursPerWeek; [ObservableProperty] private bool _isOwnClass; [ObservableProperty] private bool _isDifferentiated; [ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _gradeLevelError = ""; public List SchoolYears { get; } public List GradingOptions { get; } = ["Noten 1–6", "Punkte 0–15"]; public List KnownSubjectNames { get; private set; } = []; public LearningGroup? Result { get; private set; } public string DialogTitle => _editingGroup is null ? "Neue Lerngruppe anlegen" : "Lerngruppe bearbeiten"; public string SaveButtonText => _editingGroup is null ? "Anlegen" : "Speichern"; public AddGroupDialogViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy) { _groups = groups; _subjects = subjects; _allSubjects = subjects.GetAll(); KnownSubjectNames = _allSubjects.Select(s => s.Name).ToList(); SchoolYears = sy.RecentSchoolYears(3); SelectedSchoolYear = sy.CurrentSchoolYear(); PropertyChanged += (_, e) => { if (e.PropertyName == nameof(GradeLevel)) SelectedGradingName = GradeLevel >= 11 ? "Punkte 0–15" : "Noten 1–6"; }; } public void LoadForEdit(LearningGroup group) { _editingGroup = group; SelectedTypeName = group.Type == GroupType.Class ? "Klasse" : "Kurs"; Name = group.Name; Subject = group.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : ""; GradeLevel = group.GradeLevel; SelectedGradingName = group.GradingSystem == GradingSystem.Grades1To6 ? "Noten 1–6" : "Punkte 0–15"; SelectedSchoolYear = group.SchoolYear; HoursPerWeek = group.HoursPerWeek; IsOwnClass = group.IsOwnClass; IsDifferentiated = group.IsDifferentiated; OnPropertyChanged(nameof(DialogTitle)); OnPropertyChanged(nameof(SaveButtonText)); } [RelayCommand] private void Save() { NameError = ""; GradeLevelError = ""; var valid = true; if (string.IsNullOrWhiteSpace(Name)) { NameError = "Bezeichnung erforderlich."; valid = false; } if (GradeLevel is < 1 or > 13) { GradeLevelError = "Klassenstufe 1–13."; valid = false; } if (!valid) return; string? subjectName = !string.IsNullOrWhiteSpace(Subject) ? Subject.Trim() : null; Guid? subjectId = null; if (subjectName is not null) { var existing = _allSubjects.FirstOrDefault( s => s.Name.Equals(subjectName, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { subjectId = existing.Id; } else { var newSubject = new Subject { Name = subjectName }; _subjects.Save(newSubject); subjectId = newSubject.Id; } } Result = _editingGroup ?? new LearningGroup(); Result.Name = Name.Trim(); Result.SubjectId = subjectId; Result.Type = IsKurs ? GroupType.Course : GroupType.Class; Result.GradeLevel = GradeLevel; Result.GradingSystem = SelectedGradingName == "Punkte 0–15" ? GradingSystem.Points0To15 : GradingSystem.Grades1To6; Result.SchoolYear = SelectedSchoolYear; Result.HoursPerWeek = HoursPerWeek; Result.IsOwnClass = IsOwnClass; Result.IsDifferentiated = IsDifferentiated; _groups.Save(Result); } }