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 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 Tasks { get; } = []; public ObservableCollection GradingKeyEntries { get; } = []; public ObservableCollection 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; _duplicateSource = duplicateSource; _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(); 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 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 BuildCompetencyTagGroups(List selectedCodes) { var groups = new List(); 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 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? 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 CompetencyCodes { get; } public ObservableCollection CompetencyTagGroups { get; } = []; public string CompetencySummary => CompetencyCodes.Count == 0 ? "Keine Kompetenzen" : $"{CompetencyCodes.Count} Kompetenz(en)"; public Action? OnChanged { get; set; } public Action? OnRemove { get; set; } public Action? OnMoveUp { get; set; } public Action? OnMoveDown { get; set; } public ExamTaskEditItem(string? title, double maxPoints, double weight, List competencyCodes, List 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, }; }