Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
T

495 lines
21 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;
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 = "";
public bool IsDifferentiated { get; }
public string[] NiveauOptions => NiveauDisplay.Options;
public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"];
public bool HasSelectedOriginalExam => SelectedOriginalExam is not null;
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 Klausur anlegen" : "Klausur 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);
foreach (var exam in groupExams
.Where(e => e.Id != editingExam?.Id && e.RepeatOfExamId is null)
.Where(e => !groupExams.Any(r => r.RepeatOfExamId == e.Id && r.Id != editingExam?.Id))
.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);
if (!isDuplicate)
{
SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId);
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();
}
partial void OnUseWeightingChanged(bool value)
{
foreach (var t in Tasks) t.ShowWeight = value;
}
partial void OnSelectedOriginalExamChanged(Exam? value) => OnPropertyChanged(nameof(HasSelectedOriginalExam));
[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 (!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;
}
private void RecomputeTotals()
{
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; }
if (SelectedOriginalExam is not null && SelectedOriginalExam.Date >= date)
{
RepeatError = "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.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;
[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,
};
}