Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
adminandClaude Sonnet 5 7fbf035cbb Klausuren-Hauptseite: gruppenübergreifende Liste mit Prioritäts-Score statt Datum
Sidebar-Klausuren war bisher ein Placeholder. Neue Seite zeigt alle Klausuren des
Schuljahres sortiert nach abgeleiteter Dringlichkeit (Korrekturfortschritt statt
manuellem Status), mit Detailbereich, Parallelkurs-Umschalter und Notenspiegel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:27:18 +02:00

167 lines
6.6 KiB
C#

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;
// ── 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 string NiveauLabel => _exam.Niveau.HasValue ? NiveauDisplay.ToName(_exam.Niveau) : "";
public List<ExamTask> Tasks { get; }
public ObservableCollection<ExamResultRow> Rows { get; } = [];
public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students,
IGroupMembershipRepository memberships, GradingService grading, Exam exam, Guid groupId)
{
_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);
var membershipList = memberships.GetByGroup(groupId);
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) 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));
/// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren
/// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand
/// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte
/// Zeilen an — bereits eingetragene Punkte/Kommentare bleiben unangetastet.
[RelayCommand]
private void MarkRemainingAbsent()
{
foreach (var row in Rows.Where(r => !r.Absent && r.Cells.All(c => c.Value is null)))
row.Absent = true;
}
}
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
public partial class ExamResultRow : ObservableObject
{
private readonly Guid _resultId;
private readonly List<GradingKeyEntry> _gradingKey;
private readonly double _examMaxPoints;
private readonly GradingService _grading;
public Guid StudentId { get; }
public string Name { get; }
public ObservableCollection<PointsCell> 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<ExamResultRow>? OnChanged { get; set; }
public ExamResultRow(Guid studentId, string name, List<ExamTask> tasks, ExamResult? existing,
List<GradingKeyEntry> 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);
}