Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
T
adminandClaude Opus 5.5 d7898d5678
CI / build-and-test (push) Canceled after 0s
Punkteeingabe: Korrekturmodus wählbar, Bonusaufgaben nicht mitnummeriert
- "Enter springt: Schüler für Schüler / Aufgabe für Aufgabe" im Dialog wählbar und gemerkt;
  Umschalt+Enter zurück, Pfeiltasten springen direkt und öffnen das Eingabefeld
- Tasten per Tunnel-Handler abgefangen, bevor DataGrid/TextBox sie auswerten
- Abwesende Schüler und Aufgaben der anderen Fassung werden übersprungen
- Zellen lesen ihre Zeile aus dem DataContext (recycelte Zeilen schreiben nicht mehr falsch)
- Bonusaufgaben zählen bei der angezeigten Nummerierung nicht mit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:36:12 +02:00

255 lines
11 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) ──────────────────────────────────────────
/// Tastatur-Bewegung im Punkteraster: Next/Previous folgen dem gewählten Korrekturmodus
/// (Enter / Umschalt+Enter), die übrigen bewegen sich räumlich (Pfeiltasten).
public enum GradingMove { Next, Previous, Up, Down, Left, Right }
public partial class ExamGradingDialogViewModel : ObservableObject
{
private readonly IExamResultRepository _results;
private readonly GradingService _grading;
private readonly Exam _exam;
private readonly ExamGradingSettingsService? _settings;
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 bool HasVariants => Tasks.Any(t => t.Variant is not null);
/// Spaltenüberschriften — Bonusaufgaben zählen bei der Nummerierung nicht mit.
public List<string> TaskHeaders { get; }
public ObservableCollection<ExamResultRow> Rows { get; } = [];
public string[] EntryOrderOptions => ["Schüler für Schüler", "Aufgabe für Aufgabe"];
[ObservableProperty] private string _entryOrderName;
public GradingEntryOrder EntryOrder =>
EntryOrderName == EntryOrderOptions[1] ? GradingEntryOrder.ByTask : GradingEntryOrder.ByStudent;
public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students,
IGroupMembershipRepository memberships, GradingService grading, Exam exam, Guid groupId,
ExamGradingSettingsService? settings = null)
{
_results = results; _grading = grading; _exam = exam; _settings = settings;
Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
TaskHeaders = ExamScoringService.TaskLabels(Tasks)
.Select((label, i) => (Tasks[i].IsBonus ? "★ " : "") + label
+ (Tasks[i].Variant is null ? "" : $" ({Tasks[i].Variant})"))
.ToList();
_entryOrderName = EntryOrderOptions[
settings?.Load().EntryOrder == GradingEntryOrder.ByTask ? 1 : 0];
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,
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
row.OnChanged = SaveRow;
Rows.Add(row);
}
}
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
partial void OnEntryOrderNameChanged(string value) =>
_settings?.Save(new ExamGradingSettings { EntryOrder = EntryOrder });
/// Nur Felder, in die tatsächlich etwas eingetragen werden kann: abwesende Schüler und
/// Aufgaben der jeweils anderen Fassung werden beim Weiterspringen übersprungen.
public bool IsEditable(int rowIdx, int taskIdx) =>
rowIdx >= 0 && rowIdx < Rows.Count && taskIdx >= 0 && taskIdx < Tasks.Count
&& !Rows[rowIdx].Absent && Rows[rowIdx].Cells[taskIdx].IsApplicable;
/// Erstes Feld in Korrekturreihenfolge — dort steht der Cursor beim Öffnen des Dialogs.
public (int Row, int Task)? FirstCell() =>
IsEditable(0, 0) ? (0, 0) : NextCell(0, 0, GradingMove.Next);
/// Zielfeld für eine Tastatur-Bewegung oder null am Rand. Enter läuft je nach Modus
/// zeilen- oder spaltenweise und am Ende einer Zeile/Spalte in die nächste weiter;
/// Pfeiltasten bewegen sich nur innerhalb der Zeile bzw. Spalte und halten am Rand an.
public (int Row, int Task)? NextCell(int rowIdx, int taskIdx, GradingMove move)
{
if (move is GradingMove.Next or GradingMove.Previous)
{
var step = move == GradingMove.Next ? 1 : -1;
var byTask = EntryOrder == GradingEntryOrder.ByTask;
var total = Rows.Count * Tasks.Count;
var pos = byTask ? taskIdx * Rows.Count + rowIdx : rowIdx * Tasks.Count + taskIdx;
for (pos += step; pos >= 0 && pos < total; pos += step)
{
var (r, t) = byTask
? (pos % Rows.Count, pos / Rows.Count)
: (pos / Tasks.Count, pos % Tasks.Count);
if (IsEditable(r, t)) return (r, t);
}
return null;
}
var (dRow, dTask) = move switch
{
GradingMove.Up => (-1, 0),
GradingMove.Down => (1, 0),
GradingMove.Left => (0, -1),
_ => (0, 1),
};
for (int r = rowIdx + dRow, t = taskIdx + dTask;
r >= 0 && r < Rows.Count && t >= 0 && t < Tasks.Count;
r += dRow, t += dTask)
{
if (IsEditable(r, t)) return (r, t);
}
return null;
}
/// 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 List<ExamTask> _tasks;
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 = "";
[ObservableProperty] private string _variantName = "A";
public string[] VariantOptions => ["A", "B"];
private ExamVariant Variant => VariantName == "B" ? ExamVariant.B : ExamVariant.A;
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; _tasks = tasks;
_resultId = existing?.Id ?? Guid.NewGuid();
_absent = existing?.Absent ?? false;
_comment = existing?.Comment ?? "";
_variantName = existing?.Variant.ToString() ?? "A";
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)
{
IsApplicable = ExamScoringService.AppliesTo(tasks[i], Variant),
};
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);
partial void OnVariantNameChanged(string value)
{
for (var i = 0; i < Cells.Count; i++)
Cells[i].IsApplicable = ExamScoringService.AppliesTo(_tasks[i], Variant);
RecomputeTotals();
OnChanged?.Invoke(this);
}
private void RecomputeTotals()
{
TotalPoints = Cells.Sum(c => c.IsApplicable && !c.IsInvalid ? (c.Value ?? 0) : 0);
OnPropertyChanged(nameof(TotalPointsDisplay));
GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints,
ExamScoringService.Maximum(_tasks, Variant), _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,
Variant = Variant,
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;
[ObservableProperty] private bool _isApplicable = true;
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);
}