Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
T
adminandClaude Sonnet 5 dc2f58c4dc feat: Kompetenzzuordnungen von Freitext-Code auf stabile CompetencyItem.Id umgestellt (TODO 16.5)
Unit/Lesson/ExamTask/ParticipationSession referenzierten Kompetenzen bisher per Freitext-Code,
case-insensitiv gegen den Katalog gematcht - fragil bei Umbenennungen und seit der
Bildungsplan-Versionierung nicht mehr eindeutig (zwei Versionen können denselben Code vergeben).
Neuer Werttyp CompetencyReference (ItemId + Code/Description-Momentaufnahme) löst das: solange die
Id noch im Katalog auflösbar ist, zählt die live Fassung (Rename wirkt sofort), sonst bleibt die
gespeicherte historische Fassung sichtbar statt spurlos zu verschwinden.

Migration (Schema-Version 6) löst bestehende Codes gegen den Katalog auf; die zuvor dreifach
duplizierte Kompetenz-Tag-UI ist jetzt in CompetencyTagGroupBuilder konsolidiert.
add_lesson_competency/remove_lesson_competency nehmen jetzt itemId statt code und validieren gegen
den Katalog (vorher ungeprüft).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 00:41:54 +02:00

422 lines
17 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 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 = "–";
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,
Guid competencyCatalogVersionId = default)
{
_exams = exams; _competencyDomains = competencyDomains;
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_competencyCatalogVersionId = competencyCatalogVersionId;
_gradingSystem = gradingSystem;
_editingExam = editingExam;
_duplicateSource = duplicateSource;
_subjectName = defaultSubjectName;
IsDifferentiated = isDifferentiated;
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);
foreach (var t in source.Tasks.OrderBy(t => t.Nr))
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies]);
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 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)
{
var item = new ExamTaskEditItem(title, maxPoints, weight, competencies,
BuildCompetencyTagGroups(competencies))
{
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.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;
// 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);
}
}
// ── 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<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();
public ExamTask ToModel() => new()
{
Nr = Nr,
Title = string.IsNullOrWhiteSpace(Title) ? null : Title.Trim(),
MaxPoints = MaxPoints,
Weight = Weight,
Competencies = Competencies,
};
}