CI / build-and-test (push) Canceled after 0s
- "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>
255 lines
11 KiB
C#
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);
|
|
}
|