Für Fächer mit Binnendifferenzierung (z.B. Mathematik E/G-Niveau, Förderniveau) innerhalb derselben Lerngruppe: - LearningGroup.IsDifferentiated: neue Checkbox in den Stammdaten, aktiviert die Niveau-Funktionen optional für Klassen und Kurse. - Enrollment.Niveau: Niveau-Zuordnung je Schüler innerhalb der Gruppe, editierbar über eine neue Spalte im Schüler-Tab (nur sichtbar bei differenzierter Gruppe). - Exam.Niveau: Klausuren können optional einem Niveau zugeordnet werden. Workflow nutzt die bestehende Duplizieren-Funktion (1.1.4): G-Klausur anlegen, für die E-Variante duplizieren und eigene Aufgaben/Punkte vergeben — Niveau wird beim Duplizieren bewusst zurückgesetzt, damit nicht versehentlich zwei gleiche entstehen. - Punkteeingabe und Auswertung filtern automatisch auf die Schüler mit passendem Niveau; Klausuren ohne Niveau-Zuordnung verhalten sich unverändert (gesamte Gruppe). Beide Dialoge zeigen das Niveau als Badge im Titel, die Klausurenliste als eigene Spalte. - Individuelle Förderklausuren (nur für einen Schüler) laufen pragmatisch über die bestehende Abwesend-Markierung der übrigen Schüler statt über eine eigene Pro-Schüler-Zuordnung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
164 lines
6.5 KiB
C#
164 lines
6.5 KiB
C#
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 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,
|
|
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;
|
|
|
|
// Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
|
|
// Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
|
|
if (exam.Niveau.HasValue && enrollment?.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));
|
|
|
|
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<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);
|
|
}
|