feat: Abwesenheits-Hinweise, Fehlquote bei Zeugnisnoten, Gruppen-Dokumentation

- Schnellbewerten-Dialog: abwesende Schüler werden gedimmt und mit ihrem
  Anwesenheitsstatus statt der Aspektbeschriftung angezeigt, damit keine
  Mitarbeitsnote für nicht anwesende Schüler vergeben wird.
- Zeugnisnoten-Dialog: zeigt je Schüler die Fehlquote im gewählten
  Zeitraum, ab 50 % hervorgehoben (informativ, keine automatische
  Notenänderung).
- Gruppen-Tab "Dokumentation" (bisher Platzhalter) implementiert: listet
  alle Dokumentationseinträge der Gruppen-Schüler, mit Schüler-Filter und
  optionalem "Nur dieser Unterricht"-Schalter. Einträge aus anderen
  Lerngruppen werden standardmäßig mitangezeigt, aber gedimmt. Der
  Dokumentationsdialog bekommt dafür einen optionalen Schüler-Picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:45:01 +02:00
co-authored by Claude Sonnet 5
parent f2cb3c98c6
commit a0f44f1b25
19 changed files with 725 additions and 26 deletions
@@ -20,6 +20,9 @@ public partial class ReportGradeDialogViewModel : ObservableObject
private readonly IGroupMembershipRepository _memberships;
private readonly IGradingSchemeRepository _schemes;
private readonly IReportGradeRepository _reportGrades;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly GroupType _groupType;
@@ -51,11 +54,15 @@ public partial class ReportGradeDialogViewModel : ObservableObject
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
IParticipationSessionRepository participationSessions, IParticipationRepository participation,
AttendanceBalanceService attendanceBalance, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
{
_grades = grades; _exams = exams; _results = results; _students = students;
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
_participationSessions = participationSessions; _participation = participation;
_attendanceBalance = attendanceBalance;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
_schoolYear = schoolYear;
@@ -92,17 +99,36 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList();
// Fehlquote (Nutzer-Feedback): je Schüler die Anwesenheits-Bilanz im gewählten Zeitraum,
// nur aus Sitzungen dieser Gruppe (anders als StudentDetailViewModel.LoadAttendanceBalance,
// das gruppenübergreifend über den ganzen Schüler rechnet) — hier zählt nur, was für diese
// Zeugnisnote relevant ist.
var sessionsInPeriod = _participationSessions.GetByGroup(_groupId)
.Where(s => s.Date >= periodFrom && s.Date <= periodTo).ToList();
var attendanceByStudent = new Dictionary<Guid, List<(DateOnly Date, AttendanceStatus? Status)>>();
foreach (var session in sessionsInPeriod)
foreach (var entry in _participation.GetBySession(session.Id))
{
if (!attendanceByStudent.TryGetValue(entry.StudentId, out var list))
attendanceByStudent[entry.StudentId] = list = [];
list.Add((session.Date, entry.Attendance));
}
Rows.Clear();
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var absenceRate = _attendanceBalance.Calculate(
attendanceByStudent.TryGetValue(student.Id, out var entries) ? entries : [],
periodFrom, periodTo).AbsenceRatePercent;
var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
if (existing is { IsLocked: true })
{
Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, Save, ToggleLock));
Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, absenceRate, Save, ToggleLock));
continue;
}
@@ -128,7 +154,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
scheme, _gradingSystem, RoundingRule);
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, Save, ToggleLock));
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, absenceRate, Save, ToggleLock));
}
}
@@ -202,10 +228,16 @@ public static class RoundingRuleDisplay
public partial class ReportGradeRow : ObservableObject
{
/// Nutzer-Vorgabe: ab dieser Fehlquote darf unabhängig von der fachlichen Leistung eine 5
/// vergeben werden — reine Information/Hervorhebung, kein automatisches Übersteuern der
/// berechneten Note.
public const double HighAbsenceThresholdPercent = 50.0;
public Guid StudentId { get; }
public string Name { get; }
public string? CalculatedValue { get; private set; }
public bool IsLocked { get; private set; }
public double AbsenceRatePercent { get; }
[ObservableProperty] private string? _overrideValue;
[ObservableProperty] private string? _overrideReason;
@@ -214,17 +246,22 @@ public partial class ReportGradeRow : ObservableObject
public string CalculatedDisplay => CalculatedValue ?? "";
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay;
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
public string AbsenceRateDisplay => $"{AbsenceRatePercent:0.#} % gefehlt";
public bool HasHighAbsenceRate => AbsenceRatePercent >= HighAbsenceThresholdPercent;
// Rot wie AttendanceDisplay.Color(Truant) — dieselbe Warnfarbe wie im Mitarbeit-Feature.
public string AbsenceRateColorHex => HasHighAbsenceRate ? "#D64545" : "#8A8A8A";
public IRelayCommand SaveCommand { get; }
public IRelayCommand ToggleLockCommand { get; }
private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing,
bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
double absenceRatePercent, bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
{
StudentId = studentId;
Name = name;
CalculatedValue = calculated;
IsLocked = locked;
AbsenceRatePercent = absenceRatePercent;
_overrideValue = existing?.OverrideValue;
_overrideReason = existing?.OverrideReason;
SaveCommand = new RelayCommand(() => onSave(this));
@@ -232,12 +269,13 @@ public partial class ReportGradeRow : ObservableObject
}
public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated,
ReportGrade? existing, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, calculated, existing, existing?.IsLocked ?? false, onSave, onToggleLock);
ReportGrade? existing, double absenceRatePercent,
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, calculated, existing, absenceRatePercent, existing?.IsLocked ?? false, onSave, onToggleLock);
public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked,
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, locked.CalculatedValue, locked, true, onSave, onToggleLock);
double absenceRatePercent, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, locked.CalculatedValue, locked, absenceRatePercent, true, onSave, onToggleLock);
public void MarkSaved()
{