diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs new file mode 100644 index 0000000..2f98766 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs @@ -0,0 +1,158 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Punkteeingabe & Korrektur (1.4) ────────────────────────────────────────── + +public partial class ExamGradingDialogViewModel : ObservableObject +{ + private readonly IExamResultRepository _results; + private readonly GradingService _grading; + private readonly Exam _exam; + private readonly double _examMaxPoints; + + public string ExamTitle => _exam.Title; + public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy"); + public List Tasks { get; } + + public ObservableCollection Rows { get; } = []; + + public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students, + IEnrollmentRepository enrollments, GradingService grading, Exam exam, Guid groupId, string schoolYear) + { + _results = results; _grading = grading; _exam = exam; + Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList(); + _examMaxPoints = Tasks.Sum(t => t.MaxPoints); + + var enrolled = students.GetByGroup(groupId, schoolYear); + var enrollmentList = enrollments.GetByGroupAndYear(groupId, schoolYear); + var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId); + + foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) + { + var enrollment = enrollmentList.FirstOrDefault(e => e.StudentId == s.Id); + if (enrollment is not null && !IsEnrolledAtDate(enrollment, exam.Date)) continue; + + existing.TryGetValue(s.Id, out var result); + var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, _examMaxPoints, _grading); + row.OnChanged = SaveRow; + Rows.Add(row); + } + } + + private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id)); + + private static bool IsEnrolledAtDate(Enrollment e, DateOnly date) => e.Period switch + { + EnrollmentPeriod.H1Only => date.Month >= 8 || date.Month <= 1, + EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, + EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) + && (e.LeftAt is null || date <= e.LeftAt.Value), + _ => true, + }; +} + +// ── Zeile im Punkteraster ────────────────────────────────────────────────── + +public partial class ExamResultRow : ObservableObject +{ + private readonly Guid _resultId; + private readonly List _gradingKey; + private readonly double _examMaxPoints; + private readonly GradingService _grading; + + public Guid StudentId { get; } + public string Name { get; } + + public ObservableCollection Cells { get; } = []; + + [ObservableProperty] private bool _absent; + [ObservableProperty] private string _comment = ""; + [ObservableProperty] private double _totalPoints; + [ObservableProperty] private string _gradeDisplay = ""; + + public string TotalPointsDisplay => TotalPoints.ToString("0.##", CultureInfo.InvariantCulture); + + public Action? OnChanged { get; set; } + + public ExamResultRow(Guid studentId, string name, List tasks, ExamResult? existing, + List gradingKey, double examMaxPoints, GradingService grading) + { + StudentId = studentId; Name = name; + _gradingKey = gradingKey; _examMaxPoints = examMaxPoints; _grading = grading; + _resultId = existing?.Id ?? Guid.NewGuid(); + _absent = existing?.Absent ?? false; + _comment = existing?.Comment ?? ""; + + for (var i = 0; i < tasks.Count; i++) + { + double? pts = existing is not null && i < existing.Points.Count ? existing.Points[i] : null; + var cell = new PointsCell(tasks[i].MaxPoints, pts); + cell.OnChanged = () => { RecomputeTotals(); OnChanged?.Invoke(this); }; + Cells.Add(cell); + } + RecomputeTotals(); + } + + partial void OnAbsentChanged(bool value) + { + RecomputeTotals(); + OnChanged?.Invoke(this); + } + + partial void OnCommentChanged(string value) => OnChanged?.Invoke(this); + + private void RecomputeTotals() + { + TotalPoints = Cells.Sum(c => c.IsInvalid ? 0 : (c.Value ?? 0)); + OnPropertyChanged(nameof(TotalPointsDisplay)); + GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints, _examMaxPoints, _gradingKey); + } + + public ExamResult ToModel(Guid examId) => new() + { + Id = _resultId, + ExamId = examId, + StudentId = StudentId, + Points = Cells.Select(c => c.Value ?? 0).ToList(), + TotalPoints = TotalPoints, + Grade = Absent ? null : GradeDisplay, + Absent = Absent, + Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(), + }; +} + +// ── Eine Punktezelle ───────────────────────────────────────────────────────── + +public partial class PointsCell : ObservableObject +{ + public double MaxPoints { get; } + [ObservableProperty] private double? _value; + [ObservableProperty] private bool _isInvalid; + + public Action? OnChanged { get; set; } + + public PointsCell(double maxPoints, double? value) + { + MaxPoints = maxPoints; + _value = value; + _isInvalid = IsOutOfRange(value); + } + + /// Setzt den Wert; bei Punkten außerhalb 0..Maximalpunkte wird nur rot markiert, + /// aber nicht gespeichert (1.4.5) — OnChanged (und damit Autosave) wird dann nicht ausgelöst. + public void TrySetValue(double? value) + { + Value = value; + IsInvalid = IsOutOfRange(value); + if (!IsInvalid) OnChanged?.Invoke(); + } + + private bool IsOutOfRange(double? value) => + value.HasValue && (value.Value < 0 || value.Value > MaxPoints + 0.0001); +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index f49a029..187635d 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -176,6 +176,7 @@ public partial class GroupDetailViewModel : ObservableObject public Func>? OnEditExam { get; set; } public Func>? OnDuplicateExam { get; set; } public Func>? OnConfirmDeleteExam { get; set; } + public Func? OnGradeExam { get; set; } public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades, @@ -276,6 +277,15 @@ public partial class GroupDetailViewModel : ObservableObject } } + [RelayCommand(CanExecute = nameof(HasSelectedExam))] + private async Task GradeExam() + { + if (SelectedExam is null || OnGradeExam is null) return; + var exam = _exams.GetById(SelectedExam.Id); + if (exam is null) return; + await OnGradeExam(exam); + } + [RelayCommand(CanExecute = nameof(HasSelectedExam))] private async Task DuplicateExam() { @@ -330,6 +340,7 @@ public partial class GroupDetailViewModel : ObservableObject partial void OnSelectedExamChanged(ExamSummary? value) { EditExamCommand.NotifyCanExecuteChanged(); + GradeExamCommand.NotifyCanExecuteChanged(); DuplicateExamCommand.NotifyCanExecuteChanged(); DeleteExamCommand.NotifyCanExecuteChanged(); AdvanceExamStatusCommand.NotifyCanExecuteChanged(); diff --git a/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml new file mode 100644 index 0000000..4d47018 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + +