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>
This commit is contained in:
2026-08-11 22:00:48 +02:00
co-authored by Claude Sonnet 5
parent fd715dd5a5
commit 21378bbebd
11 changed files with 643 additions and 25 deletions
@@ -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<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,
@@ -190,9 +195,17 @@ public partial class GroupDetailViewModel : ObservableObject
$"{(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));
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",
};
}
}