From 21378bbebd1c0464594e20d9e01619441dcec246 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 11 Aug 2026 22:00:48 +0200 Subject: [PATCH] Klausurverwaltung: Anlegen, Bearbeiten, Duplizieren, Status, Aufgaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LehrerApp.Core/Models/Exam.cs | 1 + .../Repositories/AllRepositories.cs | 7 +- .../ViewModels/Groups/ExamViewModels.cs | 240 ++++++++++++++++++ .../ViewModels/Groups/GroupViewModels.cs | 110 +++++++- .../Views/Groups/DeleteExamDialog.axaml | 20 ++ .../Views/Groups/DeleteExamDialog.axaml.cs | 12 + .../Views/Groups/ExamDialog.axaml | 121 +++++++++ .../Views/Groups/ExamDialog.axaml.cs | 21 ++ .../Views/Groups/GroupDetailView.axaml | 75 +++++- .../Views/Groups/GroupDetailView.axaml.cs | 42 ++- TODO.md | 19 +- 11 files changed, 643 insertions(+), 25 deletions(-) create mode 100644 LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs create mode 100644 LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/ExamDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/ExamDialog.axaml.cs diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs index 36cff29..df6a77f 100644 --- a/LehrerApp.Core/Models/Exam.cs +++ b/LehrerApp.Core/Models/Exam.cs @@ -21,6 +21,7 @@ public class ExamTask public string? Title { get; set; } public double MaxPoints { get; set; } public double Weight { get; set; } = 1.0; + public List CompetencyCodes { get; set; } = []; } public class GradingKeyEntry { diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 42495c1..fcd7f39 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -89,7 +89,12 @@ public class ExamRepository(LiteDbContext db) : IExamRepository public List GetByGroup(Guid groupId) => db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList(); public void Save(Exam e) { e.UpdatedAt = DateTime.UtcNow; db.Exams.Upsert(e); } - public void Delete(Guid id) => db.Exams.Delete(id); + public void Delete(Guid id) + { + foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList()) + db.ExamResults.Delete(result.Id); + db.Exams.Delete(id); + } } public class ExamResultRepository(LiteDbContext db) : IExamResultRepository diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs new file mode 100644 index 0000000..58aaf48 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -0,0 +1,240 @@ +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.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Dialog: Klausur anlegen / bearbeiten / duplizieren ─────────────────────── + +public partial class ExamDialogViewModel : ObservableObject +{ + private readonly IExamRepository _exams; + private readonly ICompetencyDomainRepository _competencyDomains; + private readonly Guid _groupId; + private readonly Guid? _subjectId; + private readonly int _gradeLevel; + private readonly Exam? _editingExam; + private readonly List _gradingKey; + + [ObservableProperty] private string _title = ""; + [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); + [ObservableProperty] private string _subject = ""; + [ObservableProperty] private int? _examNumber; + [ObservableProperty] private string _notes = ""; + [ObservableProperty] private string _validationMessage = ""; + [ObservableProperty] private double _totalPoints; + [ObservableProperty] private bool _hasCompetencyCatalog; + [ObservableProperty] private bool _useWeighting; + + public bool TotalPointsWarning => TotalPoints <= 0; + public string TotalPointsDisplay => + $"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt"; + + public ObservableCollection Tasks { get; } = []; + + public Exam? Result { get; private set; } + public string DialogTitle => _editingExam is null ? "Neue Klausur anlegen" : "Klausur bearbeiten"; + public string SaveButtonText => _editingExam is null ? "Anlegen" : "Speichern"; + + public ExamDialogViewModel(IExamRepository exams, ICompetencyDomainRepository competencyDomains, + Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem, + string defaultSubjectName, Exam? editingExam, Exam? duplicateSource) + { + _exams = exams; _competencyDomains = competencyDomains; + _groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel; + _editingExam = editingExam; + + HasCompetencyCatalog = subjectId.HasValue + && _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel).Count > 0; + + var source = editingExam ?? duplicateSource; + if (source is not null) + { + var isDuplicate = duplicateSource is not null; + Title = isDuplicate ? $"{source.Title} (Kopie)" : source.Title; + DateText = isDuplicate + ? DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy") + : source.Date.ToString("dd.MM.yyyy"); + Subject = source.Subject; + ExamNumber = source.ExamNumber; + Notes = source.Notes ?? ""; + _gradingKey = source.GradingKey + .Select(k => new GradingKeyEntry { Grade = k.Grade, MinPercent = k.MinPercent }).ToList(); + foreach (var t in source.Tasks.OrderBy(t => t.Nr)) + AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.CompetencyCodes]); + UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001); + } + else + { + Subject = defaultSubjectName; + _gradingKey = gradingSystem == GradingSystem.Grades1To6 + ? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15(); + } + foreach (var t in Tasks) t.ShowWeight = UseWeighting; + RecomputeTotals(); + } + + partial void OnUseWeightingChanged(bool value) + { + foreach (var t in Tasks) t.ShowWeight = value; + } + + [RelayCommand] + private void AddTask() + { + AddTaskInternal(null, 0, 1.0, []); + Tasks[^1].ShowWeight = UseWeighting; + RecomputeTotals(); + } + + private void AddTaskInternal(string? title, double maxPoints, double weight, List competencyCodes) + { + var item = new ExamTaskEditItem(title, maxPoints, weight, competencyCodes, + BuildCompetencyTagGroups(competencyCodes)) + { + OnChanged = RecomputeTotals, + OnRemove = RemoveTask, + OnMoveUp = MoveTaskUp, + OnMoveDown = MoveTaskDown, + }; + Tasks.Add(item); + RenumberTasks(); + } + + private List BuildCompetencyTagGroups(List selectedCodes) + { + var groups = new List(); + if (!_subjectId.HasValue) return groups; + var selected = selectedCodes.ToHashSet(); + foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel)) + { + var group = new CompetencyTagGroup(domain.Name, domain.Code); + foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) + group.Items.Add(new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code))); + if (group.Items.Count > 0) groups.Add(group); + } + return groups; + } + + private void RemoveTask(ExamTaskEditItem item) + { + Tasks.Remove(item); + RenumberTasks(); + RecomputeTotals(); + } + + private void MoveTaskUp(ExamTaskEditItem item) + { + var idx = Tasks.IndexOf(item); + if (idx <= 0) return; + Tasks.Move(idx, idx - 1); + RenumberTasks(); + } + + private void MoveTaskDown(ExamTaskEditItem item) + { + var idx = Tasks.IndexOf(item); + if (idx < 0 || idx >= Tasks.Count - 1) return; + Tasks.Move(idx, idx + 1); + RenumberTasks(); + } + + private void RenumberTasks() + { + for (var i = 0; i < Tasks.Count; i++) Tasks[i].Nr = i + 1; + } + + private void RecomputeTotals() + { + TotalPoints = Tasks.Sum(t => t.MaxPoints); + OnPropertyChanged(nameof(TotalPointsWarning)); + OnPropertyChanged(nameof(TotalPointsDisplay)); + } + + [RelayCommand] + private void Save() + { + if (string.IsNullOrWhiteSpace(Title)) { ValidationMessage = "Titel erforderlich."; return; } + if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) + { + ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; + return; + } + + Result = _editingExam ?? new Exam { GroupId = _groupId }; + Result.Title = Title.Trim(); + Result.Date = date; + Result.Subject = Subject.Trim(); + Result.ExamNumber = ExamNumber; + Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); + Result.Tasks = Tasks.Select(t => t.ToModel()).ToList(); + Result.GradingKey = _gradingKey; + _exams.Save(Result); + } +} + +// ── Zeile im Aufgaben-Editor (1.2.1 / 1.2.2 / 1.2.4) ───────────────────────── + +public partial class ExamTaskEditItem : ObservableObject +{ + [ObservableProperty] private int _nr; + [ObservableProperty] private string _title = ""; + [ObservableProperty] private double _maxPoints; + [ObservableProperty] private double _weight = 1.0; + [ObservableProperty] private bool _isCompetencyPanelOpen; + [ObservableProperty] private bool _showWeight; + + public List CompetencyCodes { get; } + public ObservableCollection CompetencyTagGroups { get; } = []; + + public string CompetencySummary => CompetencyCodes.Count == 0 + ? "Keine Kompetenzen" + : $"{CompetencyCodes.Count} Kompetenz(en)"; + + public Action? OnChanged { get; set; } + public Action? OnRemove { get; set; } + public Action? OnMoveUp { get; set; } + public Action? OnMoveDown { get; set; } + + public ExamTaskEditItem(string? title, double maxPoints, double weight, + List competencyCodes, List tagGroups) + { + _title = title ?? ""; + _maxPoints = maxPoints; + _weight = weight; + CompetencyCodes = competencyCodes; + foreach (var g in tagGroups) + { + foreach (var tag in g.Items) tag.OnChanged = OnCompetencyToggled; + CompetencyTagGroups.Add(g); + } + } + + private void OnCompetencyToggled(string code, bool selected) + { + if (selected) { if (!CompetencyCodes.Contains(code)) CompetencyCodes.Add(code); } + else CompetencyCodes.Remove(code); + OnPropertyChanged(nameof(CompetencySummary)); + OnChanged?.Invoke(); + } + + [RelayCommand] private void ToggleCompetencyPanel() => IsCompetencyPanelOpen = !IsCompetencyPanelOpen; + [RelayCommand] private void Remove() => OnRemove?.Invoke(this); + [RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this); + [RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this); + + partial void OnMaxPointsChanged(double value) => OnChanged?.Invoke(); + + public ExamTask ToModel() => new() + { + Nr = Nr, + Title = string.IsNullOrWhiteSpace(Title) ? null : Title.Trim(), + MaxPoints = MaxPoints, + Weight = Weight, + CompetencyCodes = CompetencyCodes, + }; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index cd43029..f4c2bf9 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -165,12 +165,17 @@ public partial class GroupDetailViewModel : ObservableObject [ObservableProperty] private int _studentCount; [ObservableProperty] private int _activeTabIndex = 0; [ObservableProperty] private StudentSummary? _selectedStudent; + [ObservableProperty] private ExamSummary? _selectedExam; public ObservableCollection Students { get; } = []; public ObservableCollection Exams { get; } = []; public ParticipationTabViewModel ParticipationTab { get; } public Func>? OnAddStudent { get; set; } + public Func>? OnAddExam { get; set; } + public Func>? OnEditExam { get; set; } + public Func>? OnDuplicateExam { get; set; } + public Func>? OnConfirmDeleteExam { get; set; } public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades, @@ -190,9 +195,17 @@ public partial class GroupDetailViewModel : ObservableObject $"{(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); + } + + 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)); - ParticipationTab.Initialize(Group.Id, Group.SchoolYear); + SelectedExam = Exams.FirstOrDefault(e => e.Id == selectedId); } public void LoadStudents() @@ -240,7 +253,87 @@ public partial class GroupDetailViewModel : ObservableObject private bool HasSelectedStudent() => SelectedStudent is not null; - [RelayCommand] private void AddExam() { /* TODO */ } + [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); } } @@ -277,10 +370,13 @@ 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"); + Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status; StatusLabel = e.Status switch { ExamStatus.Planned => "Geplant", @@ -289,6 +385,14 @@ public class ExamSummary ExamStatus.Returned => "Zurückgegeben", _ => "", }; + StatusColorHex = e.Status switch + { + ExamStatus.Planned => "#9E9E9E", + ExamStatus.Conducted => "#FB8C00", + ExamStatus.Graded => "#43A047", + ExamStatus.Returned => "#1E88E5", + _ => "#9E9E9E", + }; } } diff --git a/LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml new file mode 100644 index 0000000..e668de7 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml @@ -0,0 +1,20 @@ + + + + + + + + +