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
@@ -0,0 +1,132 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>
/// Gruppen-Tab "Dokumentation" (bisher Platzhalter): zeigt die Dokumentationseinträge aller
/// aktuellen/ehemaligen Schüler dieser Gruppe an einem Ort, statt sie einzeln im Schüler-Tab
/// aufsuchen zu müssen. Nutzer-Feedback: die Liste soll standardmäßig auch Einträge aus anderen
/// Lerngruppen desselben Schülers mit anzeigen (optisch abgesetzt statt ausgeblendet), damit
/// Muster aus anderen Fächern/Kursen nicht verborgen bleiben — ein Schalter blendet sie bei
/// Bedarf ganz aus.
/// </summary>
public partial class GroupDocumentationTabViewModel : ObservableObject
{
private readonly IDocumentationRepository _docs;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private Guid _groupId;
private List<StudentOption> _groupStudents = [];
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
public ObservableCollection<DocumentationItem> Entries { get; } = [];
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
[ObservableProperty] private StudentOption _selectedStudentFilter = AllStudentsOption;
[ObservableProperty] private bool _onlyThisGroup;
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
public GroupDocumentationTabViewModel(IDocumentationRepository docs, IStudentRepository students,
IGroupRepository groups)
{
_docs = docs; _students = students; _groups = groups;
}
partial void OnSelectedStudentFilterChanged(StudentOption value) => Load();
partial void OnOnlyThisGroupChanged(bool value) => Load();
public void Initialize(Guid groupId)
{
_groupId = groupId;
_groupStudents = _students.GetByGroup(groupId)
.Select(s => new StudentOption(s.Id, s.FullName)).ToList();
StudentFilterOptions.Clear();
StudentFilterOptions.Add(AllStudentsOption);
foreach (var s in _groupStudents) StudentFilterOptions.Add(s);
SelectedStudentFilter = AllStudentsOption;
Load();
}
private void Load()
{
Entries.Clear();
if (_groupStudents.Count == 0) return;
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
var groupNameCache = new Dictionary<Guid, string>();
string GroupLabel(Guid id)
{
if (groupNameCache.TryGetValue(id, out var cached)) return cached;
var label = _groups.GetById(id)?.Name ?? "";
groupNameCache[id] = label;
return label;
}
var relevantStudentIds = SelectedStudentFilter.Id == Guid.Empty
? _groupStudents.Select(s => s.Id)
: [SelectedStudentFilter.Id];
var all = relevantStudentIds
.SelectMany(id => _docs.GetByStudent(id))
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
.OrderByDescending(d => d.Date);
foreach (var d in all)
{
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""),
isOwnGroup, otherGroupLabel));
}
}
[RelayCommand]
private async Task AddDocumentation()
{
if (OnEditDocumentation is null || _groupStudents.Count == 0) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
if (result is null) return;
_docs.Save(result);
Load();
}
[RelayCommand]
private async Task EditDocumentation(DocumentationItem? item)
{
if (item is null || OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, item.Model);
if (result is null) return;
_docs.Save(result);
Load();
}
[RelayCommand]
private async Task DeleteDocumentation(DocumentationItem? item)
{
if (item is null) return;
if (OnConfirmDeleteDocumentation is not null && !await OnConfirmDeleteDocumentation(item)) return;
_docs.Delete(item.Model.Id);
Load();
}
[RelayCommand]
private async Task ConductParentCall(DocumentationItem? item)
{
if (item is null || OnConductParentCall is null) return;
var result = await OnConductParentCall(item.Model, item.StudentName);
if (result is null) return;
_docs.Save(result);
Load();
}
}
@@ -219,6 +219,7 @@ public partial class GroupDetailViewModel : ObservableObject
public PlanningTabViewModel PlanningTab { get; }
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; }
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -234,7 +235,7 @@ public partial class GroupDetailViewModel : ObservableObject
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab)
SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks;
@@ -243,6 +244,7 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
GroupDocumentationTab = groupDocumentationTab;
SeatingPlanTab.OnAssessmentChanged = () =>
{
ParticipationTab.LoadSessions();
@@ -268,6 +270,7 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab.Initialize(Group.Id, IsReadOnly);
CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
GroupDocumentationTab.Initialize(Group.Id);
}
private void ReloadExams()
@@ -361,6 +361,9 @@ public partial class ParticipationStudentRow : ObservableObject
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
/// Abwesend im Sinne der Mitarbeitsbewertung: eine Bewertung ergibt für diese Stunde keinen
/// Sinn, unabhängig davon, ob die Abwesenheit entschuldigt ist oder noch geklärt werden muss.
public bool IsAbsent => Attendance is not null and not AttendanceStatus.Present;
public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework);
public string HomeworkTooltip => HomeworkDisplay.Label(Homework);
@@ -442,6 +445,7 @@ public partial class ParticipationStudentRow : ObservableObject
};
OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip));
OnPropertyChanged(nameof(IsAbsent));
AttendanceChangedCallback?.Invoke(StudentId, Attendance);
}
@@ -451,6 +455,7 @@ public partial class ParticipationStudentRow : ObservableObject
Attendance = value;
OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip));
OnPropertyChanged(nameof(IsAbsent));
AttendanceChangedCallback?.Invoke(StudentId, value);
}
}
@@ -727,6 +732,13 @@ public partial class QuickInputViewModel : ObservableObject
[ObservableProperty] private string _currentAspectLabel = "";
[ObservableProperty] private string _currentValueLabel = "";
[ObservableProperty] private string _progressText = "";
[ObservableProperty] private bool _currentStudentIsAbsent;
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
@@ -758,6 +770,7 @@ public partial class QuickInputViewModel : ObservableObject
}
partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend));
partial void OnCurrentStudentIsAbsentChanged(bool value) => OnPropertyChanged(nameof(CurrentStudentContentOpacity));
private AspectValueType CurrentAspectType() =>
_aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType;
@@ -771,6 +784,8 @@ public partial class QuickInputViewModel : ObservableObject
var row = _rows[index];
StudentName = row.Name;
ProgressText = $"{index + 1} / {_rows.Count}";
CurrentStudentIsAbsent = row.IsAbsent;
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
AspectRows.Clear();
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
@@ -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()
{