Notenverwaltung (Kapitel 2) und Mitarbeits-Assistent

Notenübersicht der Gruppe (Matrix, Gesamt-Spalte, Sortierung, Halbjahresfilter),
Einzelnoten-Pflege samt Sammelerfassung, Gewichtungsschema mit Voreinstellung je
Gruppentyp, Zeugnisnoten-Berechnung mit Übersteuern/Festschreiben/Export und
Notenentwicklung im Schülerdetail.

Dazu Anwesenheits-/Hausaufgaben-Tracking je Mitarbeit-Sitzung, ein neuer
Mitarbeits-Assistent (Zeitleiste mit Abschnitten, automatischer Notenvorschlag,
Zusammenzug zur Halbjahresnote) und eine Dashboard-Kachel für offene
Entschuldigungen. Außerdem: verbliebene englische Begriffe in Auswahlfeldern
und Buttons auf Deutsch umgestellt.
This commit is contained in:
2026-08-12 19:19:22 +02:00
parent a1e722ace1
commit febcb0855a
34 changed files with 2503 additions and 39 deletions
@@ -0,0 +1,427 @@
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;
// ── Grading-Wizard: Mitarbeit als Zeitleiste mit Abschnitten ────────────────
public partial class ParticipationWizardDialogViewModel : ObservableObject
{
private const string AbschnittPrefix = "Abschnitt: ";
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IParticipationSectionRepository _sectionRepo;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IGradeRepository _grades;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly string _schoolYear;
private readonly GradingSystem _gradingSystem;
private readonly List<Student> _students;
private readonly List<ParticipationSession> _allSessions;
private readonly List<ParticipationSection> _sectionList;
private readonly Dictionary<string, double> _aspectWeights;
public string GroupLabel { get; }
[ObservableProperty] private int _studentIndex;
[ObservableProperty] private string _studentName = "";
[ObservableProperty] private string _progressText = "";
[ObservableProperty] private string _newSectionLabel = "";
[ObservableProperty] private string _newSectionEndDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _sectionValidationMessage = "";
[ObservableProperty] private ParticipationPeriodOption _rollupPeriod;
[ObservableProperty] private string _rollupStatusMessage = "";
public List<ParticipationPeriodOption> RollupPeriodOptions { get; } =
[
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
];
public ObservableCollection<WizardSectionGroup> Timeline { get; } = [];
public ObservableCollection<WizardSectionRow> Sections { get; } = [];
public bool CanGoPrevious => StudentIndex > 0;
public bool CanGoNext => StudentIndex < _students.Count - 1;
private Guid CurrentStudentId => _students[StudentIndex].Id;
public ParticipationWizardDialogViewModel(
IParticipationSessionRepository sessions, IParticipationRepository entries,
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
IStudentRepository students, IExamRepository exams, IExamResultRepository results,
IGradeRepository grades, GradingService grading,
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
{
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
_exams = exams; _results = results; _grades = grades; _grading = grading;
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
GroupLabel = groupLabel;
_aspectWeights = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
.ToDictionary(a => a.Key, a => a.Weight);
_students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
_allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
_sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
_rollupPeriod = RollupPeriodOptions[0];
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
if (_students.Count > 0) ShowStudent(0);
}
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
private void PreviousStudent()
{
if (StudentIndex > 0) ShowStudent(StudentIndex - 1);
}
[RelayCommand(CanExecute = nameof(CanGoNext))]
private void NextStudent()
{
if (StudentIndex < _students.Count - 1) ShowStudent(StudentIndex + 1);
}
private void ShowStudent(int index)
{
StudentIndex = index;
StudentName = _students[index].FullName;
ProgressText = $"{index + 1} / {_students.Count}";
PreviousStudentCommand.NotifyCanExecuteChanged();
NextStudentCommand.NotifyCanExecuteChanged();
BuildTimeline(CurrentStudentId);
BuildSections(CurrentStudentId);
}
// ── Zeitleiste ────────────────────────────────────────────────────────────
private void BuildTimeline(Guid studentId)
{
var points = new List<(DateOnly Date, WizardTimelinePoint Point)>();
foreach (var session in _allSessions)
{
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
points.Add((session.Date, BuildSessionPoint(session, entry, studentId)));
}
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
{
var result = _results.GetByExamAndStudent(exam.Id, studentId);
if (result is null) continue;
points.Add((exam.Date, BuildExamPoint(exam, result)));
}
points = points.OrderBy(p => p.Date).ToList();
Timeline.Clear();
foreach (var section in _sectionList)
{
var group = new WizardSectionGroup($"{section.Label}\n{section.StartDate:dd.MM.}{section.EndDate:dd.MM.}", isOpen: false);
foreach (var (date, point) in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
group.Points.Add(point);
Timeline.Add(group);
}
var openStart = ComputeOpenStart();
var openGroup = new WizardSectionGroup($"läuft seit {openStart:dd.MM.}", isOpen: true);
foreach (var (date, point) in points.Where(p => p.Date >= openStart))
openGroup.Points.Add(point);
Timeline.Add(openGroup);
}
private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
{
var ratingLabel = entry is not null ? WeightedRatingLabel(entry) : "";
var note = entry?.Note;
var tooltip = $"{session.Date:dd.MM.yyyy}" +
(ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
(string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
{
HasHomework = entry?.HomeworkMissing ?? false,
AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
};
point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
return point;
}
private WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
{
var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
}
private string WeightedRatingLabel(ParticipationEntry entry)
{
if (entry.Ratings.Count == 0) return "";
var weightSum = 0.0; var valueSum = 0.0;
foreach (var r in entry.Ratings)
{
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w;
}
if (weightSum <= 0) return "";
return RatingLabel((int)Math.Round(valueSum / weightSum, MidpointRounding.AwayFromZero));
}
private static string RatingLabel(int v) => v switch
{
>= 2 => "++", 1 => "+", 0 => "~", -1 => "", _ => "−−",
};
private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.HomeworkMissing = !entry.HomeworkMissing;
_entries.Save(entry);
point.HasHomework = entry.HomeworkMissing;
}
private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Attendance = entry.Attendance switch
{
null => AttendanceStatus.ExcusePending,
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
AttendanceStatus.Unexcused => null,
_ => null,
};
_entries.Save(entry);
point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
}
// ── Abschnitte ────────────────────────────────────────────────────────────
private DateOnly ComputeOpenStart() =>
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
private void BuildSections(Guid studentId)
{
Sections.Clear();
var studentGrades = _grades.GetByStudentAndGroup(studentId, _groupId)
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
.ToList();
foreach (var section in _sectionList)
{
var grade = studentGrades.FirstOrDefault(g => g.Note == AbschnittPrefix + section.Label);
var row = new WizardSectionRow(section.Label, section.StartDate, section.EndDate,
grade?.Value ?? "", isOpen: false);
row.OnSave = SaveSectionGrade;
Sections.Add(row);
}
var openStart = ComputeOpenStart();
var suggestion = ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today));
Sections.Add(new WizardSectionRow("(läuft)", openStart, DateOnly.FromDateTime(DateTime.Today),
suggestion ?? "", isOpen: true));
}
private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
{
var points = new List<double>();
foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end))
{
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
if (entry is null || entry.Ratings.Count == 0) continue;
var weightSum = 0.0; var valueSum = 0.0;
foreach (var r in entry.Ratings)
{
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w;
}
if (weightSum > 0) points.Add(valueSum / weightSum);
}
return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
}
private void SaveSectionGrade(WizardSectionRow row)
{
if (string.IsNullOrWhiteSpace(row.Value)) { row.StatusMessage = "Wert darf nicht leer sein."; return; }
var noteTag = AbschnittPrefix + row.Label;
var record = _grades.GetByStudentAndGroup(CurrentStudentId, _groupId)
.FirstOrDefault(g => g.Category == GradeCategory.Participation && g.Note == noteTag)
?? new Grade
{
StudentId = CurrentStudentId,
GroupId = _groupId,
Category = GradeCategory.Participation,
Note = noteTag,
Date = row.EndDate,
};
record.Value = row.Value.Trim();
_grades.Save(record);
row.StatusMessage = "Gespeichert.";
}
[RelayCommand]
private void CloseSection()
{
if (string.IsNullOrWhiteSpace(NewSectionLabel)) { SectionValidationMessage = "Bezeichnung erforderlich."; return; }
if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null,
DateTimeStyles.None, out var end))
{ SectionValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
var start = ComputeOpenStart();
if (end < start) { SectionValidationMessage = "Enddatum liegt vor Abschnittsbeginn."; return; }
var section = new ParticipationSection { GroupId = _groupId, Label = NewSectionLabel.Trim(), StartDate = start, EndDate = end };
_sectionRepo.Save(section);
_sectionList.Add(section);
foreach (var student in _students)
{
var suggestion = ComputeSuggestion(student.Id, start, end);
if (suggestion is null) continue;
_grades.Save(new Grade
{
StudentId = student.Id,
GroupId = _groupId,
Category = GradeCategory.Participation,
Note = AbschnittPrefix + section.Label,
Value = suggestion,
Date = end,
Weight = 1.0,
});
}
SectionValidationMessage = "";
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
BuildTimeline(CurrentStudentId);
BuildSections(CurrentStudentId);
}
// ── Halbjahresnote aus Abschnitten ────────────────────────────────────────
[RelayCommand]
private void ApplyRollup()
{
var periodTag = $"Mitarbeit {RollupPeriod.Label} {_schoolYear}";
var applied = 0;
foreach (var student in _students)
{
var sectionGrades = _grades.GetByStudentAndGroup(student.Id, _groupId)
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
.Where(g => InPeriod(g.Date, RollupPeriod.Period))
.Select(g => (g.Value, g.Weight))
.ToList();
if (sectionGrades.Count == 0) continue;
var average = _grading.WeightedAverage(sectionGrades);
var rounded = _grading.RoundToGrade(average, _gradingSystem, RoundingRule.Commercial);
var record = _grades.GetByStudentAndGroup(student.Id, _groupId)
.FirstOrDefault(g => g.Category == GradeCategory.Participation && g.Note == periodTag)
?? new Grade { StudentId = student.Id, GroupId = _groupId, Category = GradeCategory.Participation, Note = periodTag };
record.Value = rounded;
record.Date = DateOnly.FromDateTime(DateTime.Today);
_grades.Save(record);
applied++;
}
RollupStatusMessage = applied == 0
? "Keine Abschnittsnoten im gewählten Zeitraum."
: $"{applied} Halbjahresnote(n) aus Abschnitten übernommen.";
}
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
{
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
}
// ── Zeitleisten-Bausteine ────────────────────────────────────────────────────
public class WizardSectionGroup(string bandLabel, bool isOpen)
{
public string BandLabel { get; } = bandLabel;
public bool IsOpen { get; } = isOpen;
public ObservableCollection<WizardTimelinePoint> Points { get; } = [];
}
public partial class WizardTimelinePoint : ObservableObject
{
public string DateDisplay { get; }
public bool IsExam { get; }
public string RatingLabel { get; }
public string ExamLabel { get; }
public bool HasNote { get; }
public string TooltipText { get; }
[ObservableProperty] private bool _hasHomework;
[ObservableProperty] private string _attendanceIcon = "";
[ObservableProperty] private string _attendanceTooltip = "Anwesend";
public bool IsAbsent => AttendanceIcon.Length > 0;
public string AttendanceButtonLabel => AttendanceIcon.Length > 0 ? AttendanceIcon : "Anw";
partial void OnAttendanceIconChanged(string value)
{
OnPropertyChanged(nameof(IsAbsent));
OnPropertyChanged(nameof(AttendanceButtonLabel));
}
public IRelayCommand? ToggleHomeworkCommand { get; set; }
public IRelayCommand? CycleAttendanceCommand { get; set; }
public WizardTimelinePoint(DateOnly date, bool isExam, string ratingLabel, string examLabel,
bool hasNote, string tooltip)
{
DateDisplay = date.ToString("dd.MM.", CultureInfo.InvariantCulture);
IsExam = isExam;
RatingLabel = ratingLabel;
ExamLabel = examLabel;
HasNote = hasNote;
TooltipText = tooltip;
}
}
// ── Abschnittsnote-Zeile ─────────────────────────────────────────────────────
public partial class WizardSectionRow : ObservableObject
{
public string Label { get; }
public string RangeDisplay { get; }
public DateOnly EndDate { get; }
public bool IsOpen { get; }
[ObservableProperty] private string _value;
[ObservableProperty] private string _statusMessage = "";
public Action<WizardSectionRow>? OnSave { get; set; }
public WizardSectionRow(string label, DateOnly start, DateOnly end, string value, bool isOpen)
{
Label = label;
RangeDisplay = $"{start:dd.MM.yyyy} {end:dd.MM.yyyy}";
EndDate = end;
IsOpen = isOpen;
_value = value;
}
[RelayCommand]
private void Save() => OnSave?.Invoke(this);
}