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:
@@ -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<GradingKeyEntry> _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<ExamTaskEditItem> 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<string> 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<CompetencyTagGroup> BuildCompetencyTagGroups(List<string> selectedCodes)
|
||||
{
|
||||
var groups = new List<CompetencyTagGroup>();
|
||||
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<string> CompetencyCodes { get; }
|
||||
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
|
||||
|
||||
public string CompetencySummary => CompetencyCodes.Count == 0
|
||||
? "Keine Kompetenzen"
|
||||
: $"{CompetencyCodes.Count} Kompetenz(en)";
|
||||
|
||||
public Action? OnChanged { get; set; }
|
||||
public Action<ExamTaskEditItem>? OnRemove { get; set; }
|
||||
public Action<ExamTaskEditItem>? OnMoveUp { get; set; }
|
||||
public Action<ExamTaskEditItem>? OnMoveDown { get; set; }
|
||||
|
||||
public ExamTaskEditItem(string? title, double maxPoints, double weight,
|
||||
List<string> competencyCodes, List<CompetencyTagGroup> 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,
|
||||
};
|
||||
}
|
||||
@@ -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 ? "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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user