Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
T
adminandClaude Sonnet 5 21378bbebd Klausurverwaltung: Anlegen, Bearbeiten, Duplizieren, Status, Aufgaben
Implementiert TODO 1.1 (Klausur anlegen/verwalten) und die dazu passenden
Teile von 1.2 (Aufgabenstruktur), da beides im selben Dialog verwaltet wird:

- ExamDialog: Klausur anlegen/bearbeiten/duplizieren mit Aufgaben-Editor
  (Nr./Titel/Maximalpunkte/Gewichtung, sortierbar), Gesamtpunkte-Anzeige
  mit Warnung bei 0 Punkten, und Kompetenz-Zuordnung je Aufgabe.
- Klausuren-Tab: Kontextmenü und Toolbar für Bearbeiten/Duplizieren/
  Löschen (mit Rückfrage), farbcodierte Status-Spalte, SplitButton für
  Statuswechsel (Klick = nächster Status, Dropdown = manuelles Setzen).
- ExamRepository.Delete löscht zugehörige ExamResults kaskadierend.
- Gewichtung ist standardmäßig ausgeblendet (Checkbox zum Einblenden),
  NumericUpDown-Felder ohne Spinner-Buttons wegen eines Layout-Bugs im
  Avalonia-Standardtemplate, der das interne Textfeld auf wenige Pixel
  schrumpfen ließ.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:00:48 +02:00

613 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// ── Gruppenliste ──────────────────────────────────────────────────────────────
public partial class GroupListViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly SchoolYearService _sy;
public Action<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; }
public Func<Guid, Task>? OnEditGroup { get; set; }
public Func<GroupListItem, Task<bool>>? 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<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = [];
public GroupListViewModel(IGroupRepository groups, SchoolYearService sy)
{
_groups = groups; _sy = sy;
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();
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 filtered = string.IsNullOrWhiteSpace(SearchText) ? all
: all.Where(g => g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (g.Subject?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false));
foreach (var g in filtered.OrderBy(g => g.Name))
Groups.Add(new GroupListItem(g));
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(HasSelectedGroup))]
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 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(HasSelectedGroup))]
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;
}
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)
{
Id = g.Id;
IsActive = g.IsActive;
Name = g.Name;
Subject = g.Subject ?? "";
TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs";
GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015";
DisplayName = string.IsNullOrEmpty(g.Subject) ? g.Name : $"{g.Name} · {g.Subject}";
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 IEnrollmentRepository _enrollments;
private readonly IExamRepository _exams;
private readonly IGradeRepository _grades;
[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;
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
public ParticipationTabViewModel ParticipationTab { get; }
public Func<Task<bool>>? OnAddStudent { 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; }
public Func<ExamSummary, Task<bool>>? OnConfirmDeleteExam { get; set; }
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades,
ParticipationTabViewModel participationTab)
{
_groups = groups; _students = students; _enrollments = enrollments;
_exams = exams; _grades = grades;
ParticipationTab = participationTab;
}
public void LoadGroup(Guid id)
{
Group = _groups.GetById(id);
if (Group is null) return;
GroupTitle = Group.Name;
GroupSubtitle = $"{Group.SchoolYear} · " +
$"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " +
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015")}";
LoadStudents();
ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
}
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);
}
public void LoadStudents()
{
if (Group is null) return;
Students.Clear();
var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear);
var enrollments = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear)
.ToDictionary(e => e.StudentId);
StudentCount = enrolled.Count;
foreach (var s in enrolled)
{
enrollments.TryGetValue(s.Id, out var enr);
Students.Add(new StudentSummary(s, enr));
}
}
[RelayCommand]
private async Task AddStudent()
{
if (OnAddStudent is null) return;
var confirmed = await OnAddStudent();
if (confirmed)
{
LoadStudents();
ParticipationTab.RefreshCurrentGrid();
}
}
[RelayCommand(CanExecute = nameof(HasSelectedStudent))]
private void RemoveStudent()
{
if (Group is null || SelectedStudent is null) return;
var enrollment = _enrollments.GetByGroupAndYear(Group.Id, Group.SchoolYear)
.FirstOrDefault(e => e.StudentId == SelectedStudent.Id);
if (enrollment is null) return;
_enrollments.Delete(enrollment.Id);
LoadStudents();
SelectedStudent = null;
ParticipationTab.RefreshCurrentGrid();
}
partial void OnSelectedStudentChanged(StudentSummary? value) =>
RemoveStudentCommand.NotifyCanExecuteChanged();
private bool HasSelectedStudent() => SelectedStudent is not null;
[RelayCommand]
private async Task AddExam()
{
if (Group is null || OnAddExam is null) return;
var saved = await OnAddExam(Group.Id);
if (saved) ReloadExams();
}
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
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(HasSelectedExam))]
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(HasSelectedExam))]
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(HasSelectedExam))]
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(HasSelectedExam))]
private void SetExamStatus(ExamStatus status)
{
if (SelectedExam is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
exam.Status = status;
_exams.Save(exam);
var id = exam.Id;
ReloadExams();
SelectedExam = Exams.FirstOrDefault(e => e.Id == id);
}
partial void OnSelectedExamChanged(ExamSummary? value)
{
EditExamCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
SetExamStatusCommand.NotifyCanExecuteChanged();
}
private bool HasSelectedExam() => SelectedExam is not null;
[RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); }
}
public class StudentSummary
{
public Guid Id { get; }
public string FullName { get; }
public string PeriodLabel { get; }
public StudentSummary(Core.Models.Student s, Enrollment? e)
{
Id = s.Id;
FullName = s.FullName;
PeriodLabel = e?.Period switch
{
EnrollmentPeriod.H1Only => "H1",
EnrollmentPeriod.H2Only => "H2",
EnrollmentPeriod.Custom => BuildCustomLabel(e),
_ => "",
};
}
private static string BuildCustomLabel(Enrollment e)
{
if (e.JoinedAt.HasValue && e.LeftAt.HasValue) return $"{e.JoinedAt:dd.MM.}{e.LeftAt:dd.MM.}";
if (e.JoinedAt.HasValue) return $"ab {e.JoinedAt:dd.MM.}";
if (e.LeftAt.HasValue) return $"bis {e.LeftAt:dd.MM.}";
return "Datum";
}
}
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 ExamSummary(Core.Models.Exam e)
{
Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status;
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 IEnrollmentRepository _enrollments;
private readonly Guid _groupId;
private readonly string _schoolYear;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private StudentPickerItem? _selectedStudent;
[ObservableProperty] private string _validationMessage = "";
[ObservableProperty] private EnrollmentPeriod _period = EnrollmentPeriod.FullYear;
[ObservableProperty] private string _joinedAtText = "";
[ObservableProperty] private string _leftAtText = "";
public bool IsFullYear { get => Period == EnrollmentPeriod.FullYear; set { if (value) Period = EnrollmentPeriod.FullYear; } }
public bool IsH1Only { get => Period == EnrollmentPeriod.H1Only; set { if (value) Period = EnrollmentPeriod.H1Only; } }
public bool IsH2Only { get => Period == EnrollmentPeriod.H2Only; set { if (value) Period = EnrollmentPeriod.H2Only; } }
public bool IsCustom { get => Period == EnrollmentPeriod.Custom; set { if (value) Period = EnrollmentPeriod.Custom; } }
public bool IsCustomPeriod => Period == EnrollmentPeriod.Custom;
public ObservableCollection<StudentPickerItem> AvailableStudents { get; } = [];
public Enrollment? Result { get; private set; }
public AddStudentToGroupDialogViewModel(IStudentRepository students,
IEnrollmentRepository enrollments, Guid groupId, string schoolYear)
{
_students = students; _enrollments = enrollments;
_groupId = groupId; _schoolYear = schoolYear;
LoadAvailableStudents();
}
partial void OnPeriodChanged(EnrollmentPeriod 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 alreadyEnrolled = _enrollments.GetByGroupAndYear(_groupId, _schoolYear)
.Select(e => e.StudentId).ToHashSet();
var all = _students.GetAll();
var available = all
.Where(s => !alreadyEnrolled.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 == EnrollmentPeriod.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 Enrollment
{
StudentId = SelectedStudent.Id,
GroupId = _groupId,
SchoolYear = _schoolYear,
Period = Period,
JoinedAt = joinedAt,
LeftAt = leftAt,
};
_enrollments.Save(Result);
}
}
public class StudentPickerItem
{
public Guid Id { get; }
public string FullName { get; }
public StudentPickerItem(Student s) { Id = s.Id; FullName = s.FullName; }
}
// ── Dialog: Neue Lerngruppe anlegen ──────────────────────────────────────────
public partial class AddGroupDialogViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly SchoolYearService _sy;
private readonly IEnrollmentRepository _enrollments;
private List<Subject> _allSubjects = [];
private LearningGroup? _editingGroup;
private string? _originalSchoolYear;
public List<string> 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 16";
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private string _validationMessage = "";
public List<string> SchoolYears { get; }
public List<string> GradingOptions { get; } = ["Noten 16", "Punkte 015"];
public List<string> 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, IEnrollmentRepository enrollments)
{
_groups = groups; _subjects = subjects; _sy = sy; _enrollments = enrollments;
_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 015" : "Noten 16";
};
}
public void LoadForEdit(LearningGroup group)
{
_editingGroup = group;
_originalSchoolYear = group.SchoolYear;
SelectedTypeName = group.Type == GroupType.Class ? "Klasse" : "Kurs";
Name = group.Name;
Subject = group.Subject ?? "";
GradeLevel = group.GradeLevel;
SelectedGradingName = group.GradingSystem == GradingSystem.Grades1To6
? "Noten 16" : "Punkte 015";
SelectedSchoolYear = group.SchoolYear;
HoursPerWeek = group.HoursPerWeek;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
[RelayCommand]
private void Save()
{
if (string.IsNullOrWhiteSpace(Name)) { ValidationMessage = "Bezeichnung erforderlich."; return; }
if (GradeLevel is < 1 or > 13) { ValidationMessage = "Klassenstufe 113."; return; }
string? subjectName = IsKurs && !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.Subject = subjectName;
Result.SubjectId = subjectId;
Result.Type = IsKurs ? GroupType.Course : GroupType.Class;
Result.GradeLevel = GradeLevel;
Result.GradingSystem = SelectedGradingName == "Punkte 015"
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
Result.SchoolYear = SelectedSchoolYear;
Result.HoursPerWeek = HoursPerWeek;
_groups.Save(Result);
if (_editingGroup is not null && _originalSchoolYear != SelectedSchoolYear)
{
foreach (var enrollment in _enrollments.GetByGroup(Result.Id))
{
enrollment.SchoolYear = SelectedSchoolYear;
_enrollments.Save(enrollment);
}
}
}
}