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 IUnitRepository _units; private readonly Dictionary _membershipsByStudent; private readonly GradingService _grading; private readonly Guid _groupId; private readonly string _schoolYear; private readonly GradingSystem _gradingSystem; private readonly List _students; private readonly List _allSessions; private readonly List _sectionList; private readonly Dictionary _aspectWeights; // Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der // Gewichtung, analog zu ParticipationGradeDialogViewModel. private readonly Dictionary _aspectsByKey; 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 _newSectionLabelError = ""; [ObservableProperty] private string _newSectionEndDateTextError = ""; [ObservableProperty] private ParticipationPeriodOption _rollupPeriod; [ObservableProperty] private string _rollupStatusMessage = ""; [ObservableProperty] private double _timelineZoom = 1.0; [ObservableProperty] private bool _showQuality = true; [ObservableProperty] private bool _showQuantity = true; [ObservableProperty] private bool _showWorkphase = true; [ObservableProperty] private bool _showDataPoints = true; [ObservableProperty] private bool _showWeightedTrend = true; [ObservableProperty] private bool _showQualityTrend; [ObservableProperty] private bool _showQuantityTrend; [ObservableProperty] private bool _showWorkphaseTrend; public List RollupPeriodOptions { get; } = [ new(ParticipationPeriod.H1, "1. Halbjahr"), new(ParticipationPeriod.H2, "2. Halbjahr"), new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"), ]; public ObservableCollection Timeline { get; } = []; public ObservableCollection 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, IGroupMembershipRepository memberships, IExamRepository exams, IExamResultRepository results, IGradeRepository grades, IUnitRepository units, GradingService grading, Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel) { _sessions = sessions; _entries = entries; _sectionRepo = sectionRepo; _exams = exams; _results = results; _grades = grades; _units = units; _grading = grading; _groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem; GroupLabel = groupLabel; _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId); var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId)) .GroupBy(a => a.Key).Select(g => g.Last()).ToList(); _aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight); _aspectsByKey = applicableAspects.ToDictionary(a => a.Key); var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear); _students = students.GetByGroup(groupId) .Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership) || GroupMembershipService.Overlaps(membership, schoolYearRange.From, schoolYearRange.To)) .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}"; NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy"); 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) { if (!IsStudentActiveOn(studentId, session.Date)) continue; 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)) { if (!IsStudentActiveOn(studentId, exam.Date)) continue; var result = _results.GetByExamAndStudent(exam.Id, studentId); if (result is null) continue; points.Add((exam.Date, BuildExamPoint(exam, result))); } foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId) .Where(g => g.Category != GradeCategory.Participation) .Where(g => IsStudentActiveOn(studentId, g.Date))) { points.Add((grade.Date, BuildOtherGradePoint(grade))); } 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 weightedValue = entry is not null ? WeightedRating(entry) : null; var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : ""; 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) { Homework = entry is null ? null : HomeworkDisplay.Effective(entry), AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance), AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance), AttendanceColor = AttendanceDisplay.Color(entry?.Attendance), AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value) ?? new Dictionary(), WeightedValue = weightedValue, OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem), }; 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 static WizardTimelinePoint BuildOtherGradePoint(Grade grade) { var category = grade.Category switch { GradeCategory.Oral => "Mündlich", GradeCategory.Homework => "Hausaufgabe", GradeCategory.Project => "Projekt", _ => "Sonstige Leistung", }; var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim(); var label = $"{detail}: {grade.Value}"; return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label, hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}"); } private double? WeightedRating(ParticipationEntry entry) { if (entry.Ratings.Count == 0) return null; 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 += NormalizedValue(r) * w; weightSum += w; } return weightSum <= 0 ? null : valueSum / weightSum; } private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect) ? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints) : r.Value; 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.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry)); entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework); _entries.Save(entry); point.Homework = entry.Homework; } 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.Present, AttendanceStatus.Present => AttendanceStatus.ExcusePending, AttendanceStatus.ExcusePending => AttendanceStatus.Excused, AttendanceStatus.Excused => AttendanceStatus.Unexcused, AttendanceStatus.Unexcused => AttendanceStatus.Truant, AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent, AttendanceStatus.OtherSchoolEvent => null, _ => null, }; _entries.Save(entry); // Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen. // Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen. point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance); point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance); point.AttendanceIcon = AttendanceDisplay.ShortLabel(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)); /// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn /// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich /// sinnvollere Zäsur für eine Mitarbeit-Teilnote als das Bearbeitungsdatum im Wizard. private DateOnly SuggestSectionEndDate() { var openStart = ComputeOpenStart(); var today = DateOnly.FromDateTime(DateTime.Today); var completedUnitEnd = _units.GetByGroup(_groupId) .Where(u => u.Status == UnitStatus.Completed && u.EndDate.HasValue) .Select(u => u.EndDate!.Value) .Where(d => d >= openStart && d <= today) .OrderByDescending(d => d) .Cast() .FirstOrDefault(); return completedUnitEnd ?? 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) { if (!StudentOverlaps(studentId, section.StartDate, section.EndDate)) continue; 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(); foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end) .Where(s => IsStudentActiveOn(studentId, s.Date))) { 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 += NormalizedValue(r) * 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() { NewSectionLabelError = ""; NewSectionEndDateTextError = ""; var valid = true; if (string.IsNullOrWhiteSpace(NewSectionLabel)) { NewSectionLabelError = "Bezeichnung erforderlich."; valid = false; } DateOnly end = default; if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out end)) { NewSectionEndDateTextError = "Format TT.MM.JJJJ."; valid = false; } if (!valid) return; var start = ComputeOpenStart(); if (end < start) { NewSectionEndDateTextError = "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, }); } NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}"; NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy"); 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)) .Where(g => IsStudentActiveOn(student.Id, g.Date)) .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, }; private bool IsStudentActiveOn(Guid studentId, DateOnly date) => !_membershipsByStudent.TryGetValue(studentId, out var membership) || GroupMembershipService.IsActiveOn(membership, date); private bool StudentOverlaps(Guid studentId, DateOnly from, DateOnly to) => !_membershipsByStudent.TryGetValue(studentId, out var membership) || GroupMembershipService.Overlaps(membership, from, to); } // ── Zeitleisten-Bausteine ──────────────────────────────────────────────────── public class WizardSectionGroup(string bandLabel, bool isOpen) { public string BandLabel { get; } = bandLabel; public bool IsOpen { get; } = isOpen; public ObservableCollection Points { get; } = []; } public partial class WizardTimelinePoint : ObservableObject { public DateOnly Date { get; } public string DateDisplay { get; } public bool IsExam { get; } public string RatingLabel { get; } public string ExamLabel { get; } public bool HasNote { get; } public string TooltipText { get; } public IReadOnlyDictionary AspectValues { get; init; } = new Dictionary(); public double? WeightedValue { get; init; } public string OverallGrade { get; init; } = ""; [ObservableProperty] private HomeworkStatus? _homework; [ObservableProperty] private string _attendanceIcon = ""; [ObservableProperty] private string _attendanceTooltip = "Anwesend"; [ObservableProperty] private string _attendanceColor = ""; public bool IsAbsent => AttendanceIcon.Length > 0; public string AttendanceButtonLabel => AttendanceIcon.Length > 0 ? AttendanceIcon : "·"; public bool HasHomeworkStatus => Homework is not null; public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework); public string HomeworkTooltip => HomeworkDisplay.Label(Homework); public string HomeworkColor => HomeworkDisplay.Color(Homework); partial void OnHomeworkChanged(HomeworkStatus? value) { OnPropertyChanged(nameof(HasHomeworkStatus)); OnPropertyChanged(nameof(HomeworkSymbol)); OnPropertyChanged(nameof(HomeworkTooltip)); OnPropertyChanged(nameof(HomeworkColor)); } 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) { Date = date; 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? 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); }