Neben Wiederholung und Nachschreibarbeit gibt es im Arbeitsdialog die Art Förderarbeit (Exam.IsSupportWork). Sie darf am Tag der ersten Arbeit liegen, übernimmt dessen Datum als Vorschlag und ersetzt wie eine Nachschreibarbeit nur fehlende Noten. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
577 lines
25 KiB
C#
577 lines
25 KiB
C#
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 IExamResultRepository? _results;
|
||
private readonly ICompetencyDomainRepository _competencyDomains;
|
||
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
|
||
private readonly GradingService _grading;
|
||
private readonly Guid _groupId;
|
||
private readonly Guid? _subjectId;
|
||
private readonly int _gradeLevel;
|
||
private readonly Guid _competencyCatalogVersionId;
|
||
private readonly GradingSystem _gradingSystem;
|
||
private readonly Exam? _editingExam;
|
||
private readonly Exam? _duplicateSource;
|
||
private readonly string _subjectName;
|
||
|
||
[ObservableProperty] private string _title = "";
|
||
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
||
[ObservableProperty] private int? _examNumber;
|
||
[ObservableProperty] private string _notes = "";
|
||
[ObservableProperty] private string _returnedAtText = "";
|
||
[ObservableProperty] private string _titleError = "";
|
||
[ObservableProperty] private string _dateTextError = "";
|
||
[ObservableProperty] private string _returnedAtTextError = "";
|
||
[ObservableProperty] private string _weekLoadHint = "";
|
||
[ObservableProperty] private double _totalPoints;
|
||
[ObservableProperty] private bool _hasCompetencyCatalog;
|
||
[ObservableProperty] private bool _useWeighting;
|
||
[ObservableProperty] private GradingKeyTemplate? _selectedTemplate;
|
||
[ObservableProperty] private string _newTemplateName = "";
|
||
[ObservableProperty] private string _gradingKeyValidation = "";
|
||
[ObservableProperty] private string _selectedNiveauName = "–";
|
||
[ObservableProperty] private Exam? _selectedOriginalExam;
|
||
[ObservableProperty] private string _repeatRuleName = "Bessere zählt";
|
||
[ObservableProperty] private string _repeatError = "";
|
||
[ObservableProperty] private string _taskPointsError = "";
|
||
[ObservableProperty] private string _kindName = ExamKind.Klausur.Label();
|
||
[ObservableProperty] private string _followUpKindName = WiederholungName;
|
||
|
||
private const string WiederholungName = "Wiederholung";
|
||
private const string NachschreibarbeitName = "Nachschreibarbeit";
|
||
private const string FoerderarbeitName = "Förderarbeit";
|
||
// Beim Befüllen aus einer vorhandenen Arbeit darf die Datums-Übernahme das gespeicherte Datum
|
||
// nicht überschreiben.
|
||
private bool _initializing = true;
|
||
|
||
public bool IsDifferentiated { get; }
|
||
public string[] NiveauOptions => NiveauDisplay.Options;
|
||
public string[] KindOptions { get; } = Enum.GetValues<ExamKind>().Select(k => k.Label()).ToArray();
|
||
public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"];
|
||
public string[] FollowUpKindOptions { get; } = [WiederholungName, NachschreibarbeitName, FoerderarbeitName];
|
||
public bool IsMakeUp => FollowUpKindName == NachschreibarbeitName;
|
||
public bool IsSupportWork => FollowUpKindName == FoerderarbeitName;
|
||
public bool HasSelectedOriginalExam => SelectedOriginalExam is not null;
|
||
/// Art und Zählweise sind bei einer verknüpften Folgearbeit festgelegt: sie erbt die Art der
|
||
/// ersten Arbeit, Nachschreib- und Förderarbeiten ersetzen nur fehlende Noten (keine Regelwahl nötig).
|
||
public bool CanChooseKind => !HasSelectedOriginalExam;
|
||
public bool ShowRepeatRule => HasSelectedOriginalExam && FollowUpKindName == WiederholungName;
|
||
public string LinkedExamLabel => FollowUpKindName switch
|
||
{
|
||
NachschreibarbeitName => "Nachschreibarbeit zu (optional)",
|
||
FoerderarbeitName => "Förderarbeit zu (optional)",
|
||
_ => "Wiederholung, Nachschreib- oder Förderarbeit zu (optional)",
|
||
};
|
||
private ExamKind Kind => Enum.GetValues<ExamKind>().FirstOrDefault(k => k.Label() == KindName);
|
||
public ObservableCollection<Exam> AvailableOriginalExams { get; } = [];
|
||
|
||
public bool TotalPointsWarning => TotalPoints <= 0;
|
||
public string TotalPointsDisplay => Tasks.Any(t => t.VariantName != "Beide")
|
||
? $"A: {TotalPoints:0.##} · B: {Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints):0.##} Punkte (ohne Bonus)"
|
||
: $"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt (ohne Bonus)";
|
||
|
||
public ObservableCollection<ExamTaskEditItem> Tasks { get; } = [];
|
||
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
|
||
public ObservableCollection<GradingKeyTemplate> AvailableTemplates { get; } = [];
|
||
|
||
public Exam? Result { get; private set; }
|
||
public string DialogTitle => _editingExam is null ? "Neue Arbeit anlegen" : "Arbeit bearbeiten";
|
||
public string SaveButtonText => _editingExam is null ? "Anlegen" : "Speichern";
|
||
|
||
/// Fach kommt von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein Fach).
|
||
public string SubjectDisplay => string.IsNullOrWhiteSpace(_subjectName)
|
||
? "Kein Fach hinterlegt (siehe Lerngruppe)" : $"Fach: {_subjectName}";
|
||
|
||
public ExamDialogViewModel(IExamRepository exams, ICompetencyDomainRepository competencyDomains,
|
||
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading,
|
||
Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem,
|
||
string defaultSubjectName, bool isDifferentiated, Exam? editingExam, Exam? duplicateSource,
|
||
Guid competencyCatalogVersionId = default, IExamResultRepository? results = null)
|
||
{
|
||
_exams = exams; _competencyDomains = competencyDomains;
|
||
_results = results;
|
||
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
|
||
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
||
_competencyCatalogVersionId = competencyCatalogVersionId;
|
||
_gradingSystem = gradingSystem;
|
||
_editingExam = editingExam;
|
||
_duplicateSource = duplicateSource;
|
||
_subjectName = defaultSubjectName;
|
||
IsDifferentiated = isDifferentiated;
|
||
var groupExams = _exams.GetByGroup(groupId);
|
||
// Mehrere Folgearbeiten je erster Arbeit sind erlaubt (z. B. Nachschreibtermin und
|
||
// später eine Wiederholung); Folgearbeiten selbst sind keine Bezugsarbeit.
|
||
foreach (var exam in groupExams
|
||
.Where(e => e.Id != editingExam?.Id && e.RepeatOfExamId is null)
|
||
.OrderByDescending(e => e.Date))
|
||
AvailableOriginalExams.Add(exam);
|
||
|
||
HasCompetencyCatalog = subjectId.HasValue
|
||
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel, competencyCatalogVersionId).Count > 0;
|
||
|
||
foreach (var t in _gradingKeyTemplates.GetByGradingSystem(gradingSystem))
|
||
AvailableTemplates.Add(t);
|
||
|
||
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");
|
||
ExamNumber = source.ExamNumber;
|
||
Notes = source.Notes ?? "";
|
||
ReturnedAtText = isDuplicate ? "" : source.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
|
||
// Beim Duplizieren bewusst kein Niveau übernehmen — meist wird dupliziert, um
|
||
// gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen.
|
||
SelectedNiveauName = isDuplicate ? "–" : NiveauDisplay.ToName(source.Niveau);
|
||
KindName = source.Kind.Label();
|
||
if (!isDuplicate)
|
||
{
|
||
SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId);
|
||
if (SelectedOriginalExam is not null)
|
||
FollowUpKindName = source.IsSupportWork ? FoerderarbeitName
|
||
: source.IsMakeUp ? NachschreibarbeitName : WiederholungName;
|
||
RepeatRuleName = source.RepeatGradeRule switch
|
||
{
|
||
RepeatGradeRule.Repeat => "Wiederholung zählt",
|
||
RepeatGradeRule.Original => "Erste Arbeit zählt",
|
||
_ => "Bessere zählt",
|
||
};
|
||
}
|
||
foreach (var t in source.Tasks.OrderBy(t => t.Nr))
|
||
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies], t.Variant, t.IsBonus);
|
||
UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001);
|
||
foreach (var e in source.GradingKey.OrderByDescending(e => e.MinPercent))
|
||
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
|
||
}
|
||
else
|
||
{
|
||
var defaults = gradingSystem == GradingSystem.Grades1To6
|
||
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
|
||
foreach (var e in defaults)
|
||
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
|
||
}
|
||
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
|
||
RecomputeTotals();
|
||
UpdateWeekLoadHint();
|
||
_initializing = false;
|
||
}
|
||
|
||
partial void OnUseWeightingChanged(bool value)
|
||
{
|
||
foreach (var t in Tasks) t.ShowWeight = value;
|
||
}
|
||
partial void OnSelectedOriginalExamChanged(Exam? value)
|
||
{
|
||
if (value is not null) KindName = value.Kind.Label();
|
||
else FollowUpKindName = WiederholungName;
|
||
OnPropertyChanged(nameof(HasSelectedOriginalExam));
|
||
OnPropertyChanged(nameof(CanChooseKind));
|
||
OnPropertyChanged(nameof(ShowRepeatRule));
|
||
TakeOverOriginalDateForSupportWork();
|
||
UpdateWeekLoadHint();
|
||
}
|
||
|
||
partial void OnFollowUpKindNameChanged(string value)
|
||
{
|
||
OnPropertyChanged(nameof(IsMakeUp));
|
||
OnPropertyChanged(nameof(IsSupportWork));
|
||
OnPropertyChanged(nameof(ShowRepeatRule));
|
||
OnPropertyChanged(nameof(LinkedExamLabel));
|
||
TakeOverOriginalDateForSupportWork();
|
||
}
|
||
|
||
/// Eine Förderarbeit wird meist parallel zur ersten Arbeit geschrieben: das Datum der
|
||
/// ersten Arbeit ist der Vorschlag, bleibt aber änderbar.
|
||
private void TakeOverOriginalDateForSupportWork()
|
||
{
|
||
if (_initializing || !IsSupportWork || SelectedOriginalExam is null) return;
|
||
DateText = SelectedOriginalExam.Date.ToString("dd.MM.yyyy");
|
||
}
|
||
|
||
partial void OnKindNameChanged(string value) => UpdateWeekLoadHint();
|
||
|
||
[RelayCommand] private void ClearOriginalExam() => SelectedOriginalExam = null;
|
||
|
||
partial void OnDateTextChanged(string value) => UpdateWeekLoadHint();
|
||
|
||
/// Nutzer-Feedback: persönliche Klausurlast pro Woche — die klassenbezogene
|
||
/// Kollisionsprüfung übernimmt bereits der schulische Klausurplaner (siehe
|
||
/// ExamWeekLoadService), hier geht es um die eigene Häufung über alle Kurse hinweg.
|
||
private void UpdateWeekLoadHint()
|
||
{
|
||
if (!Kind.CountsAsWritten()
|
||
|| !DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||
{
|
||
WeekLoadHint = "";
|
||
return;
|
||
}
|
||
|
||
var existing = ExamWeekLoadService.CountInSameWeek(_exams.GetAll(), date, _editingExam?.Id);
|
||
var totalIfSaved = existing + 1;
|
||
WeekLoadHint = totalIfSaved >= ExamWeekLoadService.WarningThreshold
|
||
? $"Damit hättest du {totalIfSaved} Klausuren in dieser Woche."
|
||
: "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddTask()
|
||
{
|
||
AddTaskInternal(null, 0, 1.0, []);
|
||
Tasks[^1].ShowWeight = UseWeighting;
|
||
RecomputeTotals();
|
||
}
|
||
|
||
private void AddTaskInternal(string? title, double maxPoints, double weight, List<CompetencyReference> competencies,
|
||
ExamVariant? variant = null, bool isBonus = false)
|
||
{
|
||
var item = new ExamTaskEditItem(title, maxPoints, weight, competencies,
|
||
BuildCompetencyTagGroups(competencies))
|
||
{
|
||
VariantName = variant?.ToString() ?? "Beide",
|
||
IsBonus = isBonus,
|
||
OnChanged = RecomputeTotals,
|
||
OnRemove = RemoveTask,
|
||
OnMoveUp = MoveTaskUp,
|
||
OnMoveDown = MoveTaskDown,
|
||
};
|
||
Tasks.Add(item);
|
||
RenumberTasks();
|
||
}
|
||
|
||
private List<CompetencyTagGroup> BuildCompetencyTagGroups(List<CompetencyReference> selected)
|
||
{
|
||
if (!_subjectId.HasValue) return [];
|
||
var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId);
|
||
// onChanged wird hier bewusst nicht gesetzt (bleibt null) - ExamTaskEditItem verdrahtet
|
||
// tag.OnChanged selbst auf seinen eigenen OnCompetencyToggled-Handler, siehe Konstruktor.
|
||
return CompetencyTagGroupBuilder.Build(catalog, selected, _ => { });
|
||
}
|
||
|
||
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;
|
||
var numbers = ExamScoringService.TaskNumbers(Tasks.Select(t => t.IsBonus));
|
||
for (var i = 0; i < Tasks.Count; i++) Tasks[i].DisplayNr = Tasks[i].IsBonus ? "★" : numbers[i];
|
||
}
|
||
|
||
private void RecomputeTotals()
|
||
{
|
||
RenumberTasks();
|
||
TotalPoints = Tasks.Where(t => !t.IsBonus && t.VariantName != "B").Sum(t => t.MaxPoints);
|
||
OnPropertyChanged(nameof(TotalPointsWarning));
|
||
OnPropertyChanged(nameof(TotalPointsDisplay));
|
||
RecomputeGradingKeyAbsolutes();
|
||
}
|
||
|
||
// ── Notenschlüssel (1.3) ──────────────────────────────────────────────────
|
||
|
||
private void RecomputeGradingKeyAbsolutes()
|
||
{
|
||
foreach (var e in GradingKeyEntries)
|
||
e.AbsolutePointsDisplay = TotalPoints <= 0
|
||
? "–"
|
||
: Tasks.Any(t => t.VariantName != "Beide")
|
||
? $"A ab {e.MinPercent / 100.0 * TotalPoints:0.##} / " +
|
||
$"B ab {e.MinPercent / 100.0 * Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints):0.##} Punkten"
|
||
: $"ab {(e.MinPercent / 100.0 * TotalPoints).ToString("0.##", CultureInfo.InvariantCulture)} " +
|
||
$"von {TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddGradingKeyRow()
|
||
{
|
||
AddGradingKeyRowInternal("", 0);
|
||
RecomputeGradingKeyAbsolutes();
|
||
}
|
||
|
||
private void AddGradingKeyRowInternal(string grade, double minPercent)
|
||
{
|
||
var item = new GradingKeyEntryEditItem
|
||
{
|
||
Grade = grade,
|
||
MinPercent = minPercent,
|
||
OnChanged = RecomputeGradingKeyAbsolutes,
|
||
OnRemove = RemoveGradingKeyRow,
|
||
};
|
||
GradingKeyEntries.Add(item);
|
||
}
|
||
|
||
private void RemoveGradingKeyRow(GradingKeyEntryEditItem item)
|
||
{
|
||
GradingKeyEntries.Remove(item);
|
||
RecomputeGradingKeyAbsolutes();
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedTemplate))]
|
||
private void ApplyTemplate()
|
||
{
|
||
if (SelectedTemplate is null) return;
|
||
GradingKeyEntries.Clear();
|
||
foreach (var e in SelectedTemplate.Entries.OrderByDescending(x => x.MinPercent))
|
||
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
|
||
RecomputeGradingKeyAbsolutes();
|
||
GradingKeyValidation = "";
|
||
}
|
||
|
||
private bool HasSelectedTemplate() => SelectedTemplate is not null;
|
||
|
||
partial void OnSelectedTemplateChanged(GradingKeyTemplate? value) => ApplyTemplateCommand.NotifyCanExecuteChanged();
|
||
|
||
[RelayCommand]
|
||
private void SaveAsTemplate()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(NewTemplateName)) { GradingKeyValidation = "Vorlagenname erforderlich."; return; }
|
||
|
||
var entries = BuildGradingKeyEntries();
|
||
var error = _grading.ValidateGradingKey(entries);
|
||
if (error is not null) { GradingKeyValidation = error; return; }
|
||
|
||
var template = new GradingKeyTemplate
|
||
{
|
||
Name = NewTemplateName.Trim(),
|
||
GradingSystem = _gradingSystem,
|
||
Entries = entries,
|
||
};
|
||
_gradingKeyTemplates.Save(template);
|
||
AvailableTemplates.Add(template);
|
||
NewTemplateName = "";
|
||
GradingKeyValidation = "";
|
||
}
|
||
|
||
private List<GradingKeyEntry> BuildGradingKeyEntries() => GradingKeyEntries
|
||
.Select(e => new GradingKeyEntry { Grade = e.Grade.Trim(), MinPercent = e.MinPercent })
|
||
.OrderByDescending(e => e.MinPercent)
|
||
.ToList();
|
||
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
TitleError = ""; DateTextError = ""; ReturnedAtTextError = ""; GradingKeyValidation = "";
|
||
RepeatError = ""; TaskPointsError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||
|
||
DateOnly date = default;
|
||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out date))
|
||
{
|
||
DateTextError = "Format TT.MM.JJJJ.";
|
||
valid = false;
|
||
}
|
||
|
||
DateOnly? returnedAt = null;
|
||
if (!string.IsNullOrWhiteSpace(ReturnedAtText))
|
||
{
|
||
if (!DateOnly.TryParseExact(ReturnedAtText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
|
||
{
|
||
ReturnedAtTextError = "Format TT.MM.JJJJ.";
|
||
valid = false;
|
||
}
|
||
else returnedAt = r;
|
||
}
|
||
|
||
var gradingKey = BuildGradingKeyEntries();
|
||
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
|
||
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
|
||
// Datumsprüfung nur bei gültigem Datum — sonst meldet das Datumsfeld den Fehler bereits.
|
||
// Eine Förderarbeit entsteht parallel zur ersten Arbeit und darf am selben Tag liegen.
|
||
if (SelectedOriginalExam is not null && DateTextError == "")
|
||
{
|
||
if (IsSupportWork)
|
||
{
|
||
if (SelectedOriginalExam.Date > date)
|
||
{
|
||
RepeatError = "Die Förderarbeit darf nicht vor der ersten Arbeit liegen.";
|
||
valid = false;
|
||
}
|
||
}
|
||
else if (SelectedOriginalExam.Date >= date)
|
||
{
|
||
RepeatError = IsMakeUp
|
||
? "Die erste Arbeit muss vor der Nachschreibarbeit liegen."
|
||
: "Die erste Arbeit muss vor der Wiederholung liegen.";
|
||
valid = false;
|
||
}
|
||
}
|
||
if (Tasks.Any(t => t.VariantName != "Beide") &&
|
||
(TotalPoints <= 0 || Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints) <= 0))
|
||
{
|
||
TaskPointsError = "Fassung A und B brauchen jeweils Grundpunkte.";
|
||
valid = false;
|
||
}
|
||
|
||
if (!valid) return;
|
||
|
||
Result = _editingExam ?? new Exam { GroupId = _groupId };
|
||
Result.Title = Title.Trim();
|
||
Result.Date = date;
|
||
Result.ExamNumber = ExamNumber;
|
||
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
|
||
Result.ReturnedAt = returnedAt;
|
||
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
|
||
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
||
Result.GradingKey = gradingKey;
|
||
Result.RepeatOfExamId = SelectedOriginalExam?.Id;
|
||
Result.Kind = SelectedOriginalExam?.Kind ?? Kind;
|
||
Result.IsMakeUp = IsMakeUp && SelectedOriginalExam is not null;
|
||
Result.IsSupportWork = IsSupportWork && SelectedOriginalExam is not null;
|
||
Result.RepeatGradeRule = RepeatRuleName switch
|
||
{
|
||
"Wiederholung zählt" => RepeatGradeRule.Repeat,
|
||
"Erste Arbeit zählt" => RepeatGradeRule.Original,
|
||
_ => RepeatGradeRule.Better,
|
||
};
|
||
// Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die
|
||
// Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst
|
||
// angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter.
|
||
if (_duplicateSource is not null)
|
||
Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id;
|
||
_exams.Save(Result);
|
||
if (_editingExam is not null && _results is not null)
|
||
{
|
||
var tasks = Result.Tasks.OrderBy(t => t.Nr).ToList();
|
||
var existingResults = _results.GetByExam(Result.Id);
|
||
foreach (var result in existingResults.Where(r => !r.Absent))
|
||
{
|
||
result.TotalPoints = ExamScoringService.Achieved(tasks, result.Points, result.Variant);
|
||
result.Grade = _grading.CalculateGrade(result.TotalPoints,
|
||
ExamScoringService.Maximum(tasks, result.Variant), gradingKey);
|
||
}
|
||
_results.SaveMany(existingResults);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Zeile im Notenschlüssel-Editor (1.3) ──────────────────────────────────────
|
||
|
||
public partial class GradingKeyEntryEditItem : ObservableObject
|
||
{
|
||
[ObservableProperty] private string _grade = "";
|
||
[ObservableProperty] private double _minPercent;
|
||
[ObservableProperty] private string _absolutePointsDisplay = "";
|
||
|
||
public Action? OnChanged { get; set; }
|
||
public Action<GradingKeyEntryEditItem>? OnRemove { get; set; }
|
||
|
||
partial void OnMinPercentChanged(double value) => OnChanged?.Invoke();
|
||
partial void OnGradeChanged(string value) => OnChanged?.Invoke();
|
||
|
||
[RelayCommand] private void Remove() => OnRemove?.Invoke(this);
|
||
}
|
||
|
||
// ── Zeile im Aufgaben-Editor (1.2.1 / 1.2.2 / 1.2.4) ─────────────────────────
|
||
|
||
public partial class ExamTaskEditItem : ObservableObject
|
||
{
|
||
[ObservableProperty] private int _nr;
|
||
/// Angezeigte Nummer — Bonusaufgaben zählen nicht mit (siehe ExamScoringService.TaskNumbers).
|
||
[ObservableProperty] private string _displayNr = "";
|
||
[ObservableProperty] private string _title = "";
|
||
[ObservableProperty] private double _maxPoints;
|
||
[ObservableProperty] private double _weight = 1.0;
|
||
[ObservableProperty] private bool _isCompetencyPanelOpen;
|
||
[ObservableProperty] private bool _showWeight;
|
||
[ObservableProperty] private string _variantName = "Beide";
|
||
[ObservableProperty] private bool _isBonus;
|
||
public string[] VariantOptions => ["Beide", "A", "B"];
|
||
|
||
public List<CompetencyReference> Competencies { get; }
|
||
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
|
||
|
||
public string CompetencySummary => Competencies.Count == 0
|
||
? "Keine Kompetenzen"
|
||
: $"{Competencies.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<CompetencyReference> competencies, List<CompetencyTagGroup> tagGroups)
|
||
{
|
||
_title = title ?? "";
|
||
_maxPoints = maxPoints;
|
||
_weight = weight;
|
||
Competencies = competencies;
|
||
foreach (var g in tagGroups)
|
||
{
|
||
foreach (var tag in g.Items) tag.OnChanged = OnCompetencyToggled;
|
||
CompetencyTagGroups.Add(g);
|
||
}
|
||
}
|
||
|
||
private void OnCompetencyToggled(CompetencyTag tag)
|
||
{
|
||
if (tag.IsSelected)
|
||
{
|
||
if (!Competencies.Any(r => r.ItemId == tag.ItemId))
|
||
Competencies.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description });
|
||
}
|
||
else
|
||
{
|
||
Competencies.RemoveAll(r => r.ItemId == tag.ItemId);
|
||
}
|
||
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();
|
||
partial void OnVariantNameChanged(string value) => OnChanged?.Invoke();
|
||
partial void OnIsBonusChanged(bool value) => OnChanged?.Invoke();
|
||
|
||
public ExamTask ToModel() => new()
|
||
{
|
||
Nr = Nr,
|
||
Title = string.IsNullOrWhiteSpace(Title) ? null : Title.Trim(),
|
||
MaxPoints = MaxPoints,
|
||
Weight = Weight,
|
||
Variant = VariantName switch { "A" => ExamVariant.A, "B" => ExamVariant.B, _ => null },
|
||
IsBonus = IsBonus,
|
||
Competencies = Competencies,
|
||
};
|
||
}
|