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 _students; private readonly List _allSessions; private readonly List _sectionList; private readonly Dictionary _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 = ""; [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, 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)) .GroupBy(a => a.Key) .ToDictionary(g => g.Key, g => g.Last().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))); } foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId) .Where(g => g.Category != GradeCategory.Participation)) { 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 += r.Value * w; weightSum += w; } return weightSum <= 0 ? null : valueSum / weightSum; } 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)); 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(); 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 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); }