Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
T
adminandClaude Sonnet 5 db1afff7e4 Fehlerbehandlung, Logging und feldbezogene Validierung (Kapitel 13.2)
Zentrale Exception-Behandlung (Dispatcher.UIThread.UnhandledException,
AppDomain, TaskScheduler) verhindert Abstürze und protokolliert Fehler
über AppLogger in eine rotierende Log-Datei im App-Datenverzeichnis.
Toast-Benachrichtigungen zeigen Erfolg/Fehler global an. Alle Dialoge
mit Formularfeldern zeigen Validierungsmeldungen jetzt direkt am
betroffenen Feld statt in einem Sammel-Label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:38:30 +02:00

389 lines
15 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 ICompetencyDomainRepository _competencyDomains;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly Guid? _subjectId;
private readonly int _gradeLevel;
private readonly GradingSystem _gradingSystem;
private readonly Exam? _editingExam;
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 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 = "";
public bool IsDifferentiated { get; }
public string[] NiveauOptions => NiveauDisplay.Options;
public bool TotalPointsWarning => TotalPoints <= 0;
public string TotalPointsDisplay =>
$"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt";
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)
{
_exams = exams; _competencyDomains = competencyDomains;
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_gradingSystem = gradingSystem;
_editingExam = editingExam;
_subjectName = defaultSubjectName;
IsDifferentiated = isDifferentiated;
HasCompetencyCatalog = subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel).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);
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);
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();
}
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));
RecomputeGradingKeyAbsolutes();
}
// ── Notenschlüssel (1.3) ──────────────────────────────────────────────────
private void RecomputeGradingKeyAbsolutes()
{
foreach (var e in GradingKeyEntries)
e.AbsolutePointsDisplay = TotalPoints <= 0
? ""
: $"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 = "";
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 (!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;
_exams.Save(Result);
}
}
// ── 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;
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,
};
}