diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 658c97c..da68346 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -56,6 +56,20 @@ public interface IGradeRepository void Save(Grade grade); void Delete(Guid id); } +public interface IGradingSchemeRepository +{ + GradingScheme? GetByGroup(Guid groupId); + GradingScheme? GetDefaultForType(GroupType type); + void Save(GradingScheme scheme); + void Delete(Guid id); +} +public interface IReportGradeRepository +{ + List GetByGroup(Guid groupId); + ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period); + void Save(ReportGrade grade); + void Delete(Guid id); +} public interface IUnitRepository { Unit? GetById(Guid id); @@ -116,6 +130,12 @@ public interface IParticipationAspectRepository void Save(ParticipationAspect aspect); void Delete(Guid id); } +public interface IParticipationSectionRepository +{ + List GetByGroup(Guid groupId); + void Save(ParticipationSection section); + void Delete(Guid id); +} public interface ISubjectRepository { List GetAll(); diff --git a/LehrerApp.Core/Models/Participation.cs b/LehrerApp.Core/Models/Participation.cs index 4455cc4..9a8363c 100644 --- a/LehrerApp.Core/Models/Participation.cs +++ b/LehrerApp.Core/Models/Participation.cs @@ -20,9 +20,34 @@ public class ParticipationEntry public List Ratings { get; set; } = []; public List CompetencyRatings { get; set; } = []; public string? Note { get; set; } + public bool HomeworkMissing { get; set; } + public AttendanceStatus? Attendance { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } +/// +/// Anwesenheitsstatus einer Sitzung; null (Standard) bedeutet anwesend. +/// ist ein bewusster Zwischenzustand, da die Entschuldigung meist +/// erst später eintrifft — er wird beim Erfassen des Fehltags gesetzt und danach manuell auf +/// oder nachgetragen. +/// +public enum AttendanceStatus { ExcusePending, Excused, Unexcused } + +/// +/// Ein Bewertungsabschnitt einer Lerngruppe (z.B. alle 4–7 Wochen), an dessen Ende eine +/// Abschnittsnote Mitarbeit vergeben wird. Nur abgeschlossene Abschnitte werden gespeichert; +/// der aktuell laufende Zeitraum ergibt sich aus dem Ende des letzten Abschnitts bis heute. +/// +public class ParticipationSection +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GroupId { get; set; } + public string Label { get; set; } = ""; + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + public class CompetencyRating { public string Code { get; set; } = ""; diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 73fd48c..4e75f07 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -14,6 +14,22 @@ public class Grade } public enum GradeCategory { Oral, Homework, Participation, Project, Other } +/// +/// Prozentuale Gewichtung von Klausuren/Mitarbeit/sonstigen Leistungen für die Zeugnisnote. +/// Entweder gruppenspezifisch ( gesetzt) oder als Voreinstellung je +/// Gruppentyp ( gesetzt, null). +/// +public class GradingScheme +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? GroupId { get; set; } + public GroupType? GroupType { get; set; } + public double ExamsPercent { get; set; } + public double ParticipationPercent { get; set; } + public double OtherPercent { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + public class Unit { public Guid Id { get; set; } = Guid.NewGuid(); @@ -45,3 +61,22 @@ public class Lesson } public enum UnitStatus { Planned, Active, Completed } public enum LessonStatus { Planned, Conducted } + +/// +/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr). +/// ist das zuletzt berechnete Ergebnis; +/// überschreibt es bei pädagogischem Ermessen (erfordert ). +/// Nach dem Festschreiben () wird der Datensatz nicht mehr neu berechnet. +/// +public class ReportGrade +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid GroupId { get; set; } + public string Period { get; set; } = ""; + public string CalculatedValue { get; set; } = ""; + public string? OverrideValue { get; set; } + public string? OverrideReason { get; set; } + public bool IsLocked { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/LehrerApp.Core/Services/GradingService.cs b/LehrerApp.Core/Services/GradingService.cs index ab9ad73..6429df2 100644 --- a/LehrerApp.Core/Services/GradingService.cs +++ b/LehrerApp.Core/Services/GradingService.cs @@ -64,6 +64,16 @@ public class GradingService return CalculateGrade(percent, 100, key); } + public string? ValidateGradingScheme(GradingScheme scheme) + { + if (scheme.ExamsPercent < 0 || scheme.ParticipationPercent < 0 || scheme.OtherPercent < 0) + return "Anteile dürfen nicht negativ sein."; + var sum = scheme.ExamsPercent + scheme.ParticipationPercent + scheme.OtherPercent; + return Math.Abs(sum - 100.0) > 0.01 + ? $"Die Anteile müssen zusammen 100 % ergeben (aktuell {sum.ToString("0.#")} %)." + : null; + } + public double WeightedAverage(List<(string Grade, double Weight)> grades) { var numeric = grades @@ -73,4 +83,51 @@ public class GradingService var total = numeric.Sum(g => g.Weight); return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total; } + + /// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl. + /// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard). + /// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note + /// (Grades1To6: kleinere Zahl ist besser → abrunden; Points0To15: größere Zahl ist besser → aufrunden). + public string RoundToGrade(double value, GradingSystem system, RoundingRule rule) + { + int rounded; + var floor = Math.Floor(value); + var isExactHalf = Math.Abs(value - floor - 0.5) < 0.0001; + + if (rule == RoundingRule.Pedagogical && isExactHalf) + { + var betterIsLower = system == GradingSystem.Grades1To6; + rounded = betterIsLower ? (int)floor : (int)floor + 1; + } + else + { + rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero); + } + + var (min, max) = system == GradingSystem.Grades1To6 ? (1, 6) : (0, 15); + return Math.Clamp(rounded, min, max).ToString(); + } + + /// Berechnet die Zeugnisnote (2.4.1) aus den drei Leistungsbereichen gemäß Gewichtungsschema. + /// Bereiche ohne Werte werden ausgelassen; die verbleibenden Prozentanteile werden neu normiert. + /// Liefert null, wenn in keinem Bereich Werte vorliegen. + public string? CalculateReportGrade( + List<(string Grade, double Weight)> examGrades, + List<(string Grade, double Weight)> participationGrades, + List<(string Grade, double Weight)> otherGrades, + GradingScheme scheme, GradingSystem system, RoundingRule rounding) + { + var buckets = new List<(double Avg, double Percent)>(); + if (examGrades.Count > 0) buckets.Add((WeightedAverage(examGrades), scheme.ExamsPercent)); + if (participationGrades.Count > 0) buckets.Add((WeightedAverage(participationGrades), scheme.ParticipationPercent)); + if (otherGrades.Count > 0) buckets.Add((WeightedAverage(otherGrades), scheme.OtherPercent)); + + var totalPercent = buckets.Sum(b => b.Percent); + if (buckets.Count == 0 || totalPercent <= 0) return null; + + var weighted = buckets.Sum(b => b.Avg * b.Percent) / totalPercent; + return RoundToGrade(weighted, system, rounding); + } } + +public enum RoundingRule { Commercial, Pedagogical } diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 264439c..0b6856f 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -26,6 +26,8 @@ public class LiteDbContext : IDisposable public ILiteCollection Exams => _db.GetCollection("exams"); public ILiteCollection ExamResults => _db.GetCollection("exam_results"); public ILiteCollection Grades => _db.GetCollection("grades"); + public ILiteCollection GradingSchemes => _db.GetCollection("grading_schemes"); + public ILiteCollection ReportGrades => _db.GetCollection("report_grades"); public ILiteCollection GradingKeyTemplates => _db.GetCollection("grading_key_templates"); public ILiteCollection Units => _db.GetCollection("units"); public ILiteCollection Lessons => _db.GetCollection("lessons"); @@ -35,6 +37,7 @@ public class LiteDbContext : IDisposable public ILiteCollection ParticipationSessions => _db.GetCollection("participation_sessions"); public ILiteCollection ParticipationEntries => _db.GetCollection("participation"); public ILiteCollection ParticipationAspects => _db.GetCollection("participation_aspects"); + public ILiteCollection ParticipationSections => _db.GetCollection("participation_sections"); public ILiteCollection Subjects => _db.GetCollection("subjects"); public ILiteCollection CompetencyDomains => _db.GetCollection("competency_domains"); @@ -152,6 +155,12 @@ public class LiteDbContext : IDisposable BsonExpression.Create("STRING($.ExamId) + ':' + STRING($.StudentId)"), unique: true); Grades.EnsureIndex(x => x.StudentId); Grades.EnsureIndex(x => x.GroupId); + GradingSchemes.EnsureIndex(x => x.GroupId); + GradingSchemes.EnsureIndex(x => x.GroupType); + ReportGrades.EnsureIndex(x => x.GroupId); + ReportGrades.EnsureIndex(x => x.StudentId); + ReportGrades.EnsureIndex("ux_student_group_period", + BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true); GradingKeyTemplates.EnsureIndex(x => x.GradingSystem); Units.EnsureIndex(x => x.GroupId); Lessons.EnsureIndex(x => x.UnitId); @@ -167,6 +176,7 @@ public class LiteDbContext : IDisposable ParticipationEntries.EnsureIndex("ux_session_student", BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true); ParticipationAspects.EnsureIndex(x => x.GroupId); + ParticipationSections.EnsureIndex(x => x.GroupId); Subjects.EnsureIndex(x => x.Name); Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); CompetencyDomains.EnsureIndex(x => x.SubjectId); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 64b7498..ef333c0 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -147,6 +147,24 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository public void Delete(Guid id) => db.Grades.Delete(id); } +public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository +{ + public GradingScheme? GetByGroup(Guid groupId) => db.GradingSchemes.FindOne(s => s.GroupId == groupId); + public GradingScheme? GetDefaultForType(GroupType type) => + db.GradingSchemes.FindOne(s => s.GroupId == null && s.GroupType == type); + public void Save(GradingScheme s) { s.UpdatedAt = DateTime.UtcNow; db.GradingSchemes.Upsert(s); } + public void Delete(Guid id) => db.GradingSchemes.Delete(id); +} + +public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository +{ + public List GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList(); + public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) => + db.ReportGrades.FindOne(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == period); + public void Save(ReportGrade r) { r.UpdatedAt = DateTime.UtcNow; db.ReportGrades.Upsert(r); } + public void Delete(Guid id) => db.ReportGrades.Delete(id); +} + public class UnitRepository(LiteDbContext db) : IUnitRepository { public Unit? GetById(Guid id) => db.Units.FindById(id); @@ -248,6 +266,14 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp public void Delete(Guid id) => db.ParticipationAspects.Delete(id); } +public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSectionRepository +{ + public List GetByGroup(Guid groupId) => + db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList(); + public void Save(ParticipationSection s) => db.ParticipationSections.Upsert(s); + public void Delete(Guid id) => db.ParticipationSections.Delete(id); +} + public class SubjectRepository(LiteDbContext db) : ISubjectRepository { public List GetAll() => db.Subjects.FindAll().OrderBy(s => s.Name).ToList(); diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index f34a194..d26c674 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -48,6 +48,8 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -57,6 +59,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -111,6 +114,7 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 60bffcd..0ddae11 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -17,8 +17,13 @@ public partial class DashboardViewModel : ObservableObject private readonly ILessonRepository _lessons; private readonly IExamRepository _exams; private readonly IWorkTaskRepository _tasks; + private readonly IParticipationSessionRepository _participationSessions; + private readonly IParticipationRepository _participationEntries; + private readonly IStudentRepository _students; private readonly SchoolYearService _sy; + private const int OpenExcuseMaxAgeDays = 21; + [ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentSchoolYear = ""; @@ -30,15 +35,19 @@ public partial class DashboardViewModel : ObservableObject public ObservableCollection OpenTasks { get; } = []; public ObservableCollection CurrentGroups { get; } = []; public ObservableCollection CalendarDays { get; } = []; + public ObservableCollection OpenExcuses { get; } = []; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; // Navigation-Callback – wird von App.axaml.cs verdrahtet public Action? OnNavigateToGroup { get; set; } public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, - IExamRepository exams, IWorkTaskRepository tasks, SchoolYearService sy) + IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions, + IParticipationRepository participationEntries, IStudentRepository students, SchoolYearService sy) { - _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _sy = sy; + _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; + _participationSessions = participationSessions; _participationEntries = participationEntries; + _students = students; _sy = sy; Load(); } @@ -80,6 +89,41 @@ public partial class DashboardViewModel : ObservableObject CalendarMonth = FirstOfMonth(now); LoadCalendar(); + LoadOpenExcuses(groups.Values.ToList(), today); + } + + private void LoadOpenExcuses(List groups, DateOnly today) + { + OpenExcuses.Clear(); + var cutoff = today.AddDays(-OpenExcuseMaxAgeDays); + + var items = new List(); + foreach (var g in groups) + { + foreach (var session in _participationSessions.GetByGroup(g.Id).Where(s => s.Date >= cutoff && s.Date <= today)) + { + foreach (var entry in _participationEntries.GetBySession(session.Id) + .Where(e => e.Attendance == AttendanceStatus.ExcusePending)) + { + var student = _students.GetById(entry.StudentId); + if (student is null) continue; + var item = new OpenExcuseItem(session.Id, entry.StudentId, student.FullName, g.Name, session.Date); + item.OnResolve = ResolveExcuse; + items.Add(item); + } + } + } + foreach (var item in items.OrderBy(i => i.Date)) + OpenExcuses.Add(item); + } + + private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status) + { + var entry = _participationEntries.GetBySessionAndStudent(item.SessionId, item.StudentId); + if (entry is null) return; + entry.Attendance = status; + _participationEntries.Save(entry); + OpenExcuses.Remove(item); } private void LoadCalendar() @@ -172,6 +216,33 @@ public class LessonItem { public string GroupName { get; set; } = ""; public str public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } } public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; } +// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ──────────────────────── + +public partial class OpenExcuseItem : ObservableObject +{ + public Guid SessionId { get; } + public Guid StudentId { get; } + public string StudentName { get; } + public string GroupName { get; } + public DateOnly Date { get; } + public string DateDisplay { get; } + + public Action? OnResolve { get; set; } + + public OpenExcuseItem(Guid sessionId, Guid studentId, string studentName, string groupName, DateOnly date) + { + SessionId = sessionId; + StudentId = studentId; + StudentName = studentName; + GroupName = groupName; + Date = date; + DateDisplay = date.ToString("dd.MM.yyyy"); + } + + [RelayCommand] private void MarkExcused() => OnResolve?.Invoke(this, AttendanceStatus.Excused); + [RelayCommand] private void MarkUnexcused() => OnResolve?.Invoke(this, AttendanceStatus.Unexcused); +} + public class CalendarDayCell { public int DayNumber { get; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs new file mode 100644 index 0000000..393a1ec --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs @@ -0,0 +1,461 @@ +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; + +// ── Notenübersicht der Gruppe (2.1) ────────────────────────────────────────── + +public partial class GradeOverviewTabViewModel : ObservableObject +{ + private readonly IGradeRepository _grades; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _results; + private readonly IStudentRepository _students; + private readonly IGroupMembershipRepository _memberships; + private readonly GradingService _grading; + + private Guid _groupId; + private GradingSystem _gradingSystem; + private GroupType _groupType; + private string _groupLabel = ""; + private bool _sortByTotal; + private bool _sortDescending; + + public Guid GroupId => _groupId; + public GradingSystem GradingSystem => _gradingSystem; + public GroupType GroupType => _groupType; + public string GroupLabel => _groupLabel; + + [ObservableProperty] private ParticipationPeriodOption _selectedPeriod; + [ObservableProperty] private bool _showAsPoints = true; + [ObservableProperty] private GradeOverviewRow? _selectedRow; + [ObservableProperty] private int _rebuildColumnsSignal; + + public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15; + + public List PeriodOptions { get; } = + [ + new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"), + new(ParticipationPeriod.H1, "1. Halbjahr"), + new(ParticipationPeriod.H2, "2. Halbjahr"), + ]; + + public ObservableCollection Columns { get; } = []; + public ObservableCollection Rows { get; } = []; + + public Func? OnManageStudentGrades { get; set; } + public Func? OnCollectiveGrade { get; set; } + public Func? OnReportGrades { get; set; } + + public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams, + IExamResultRepository results, IStudentRepository students, + IGroupMembershipRepository memberships, GradingService grading) + { + _grades = grades; _exams = exams; _results = results; + _students = students; _memberships = memberships; _grading = grading; + _selectedPeriod = PeriodOptions[0]; + } + + public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType, string groupLabel) + { + _groupId = groupId; + _gradingSystem = gradingSystem; + _groupType = groupType; + _groupLabel = groupLabel; + ShowAsPoints = gradingSystem == GradingSystem.Points0To15; + OnPropertyChanged(nameof(CanTogglePointsView)); + Recompute(); + } + + public void Refresh() => Recompute(); + + partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute(); + partial void OnShowAsPointsChanged(bool value) => Recompute(); + + [RelayCommand] + private void SortByName() + { + if (!_sortByTotal) _sortDescending = !_sortDescending; + else { _sortByTotal = false; _sortDescending = false; } + ApplySort(); + } + + [RelayCommand] + private void SortByTotal() + { + if (_sortByTotal) _sortDescending = !_sortDescending; + else { _sortByTotal = true; _sortDescending = false; } + ApplySort(); + } + + [RelayCommand] + private async Task ManageStudentGrades() + { + if (SelectedRow is null || OnManageStudentGrades is null) return; + await OnManageStudentGrades(SelectedRow); + Recompute(); + } + + [RelayCommand] + private async Task CollectiveGrade() + { + if (OnCollectiveGrade is null) return; + await OnCollectiveGrade(); + Recompute(); + } + + [RelayCommand] + private async Task ReportGrades() + { + if (OnReportGrades is null) return; + await OnReportGrades(); + } + + private void Recompute() + { + var period = SelectedPeriod.Period; + + var students = _students.GetByGroup(_groupId); + var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId); + + var exams = _exams.GetByGroup(_groupId) + .Where(e => InPeriod(e.Date, period)) + .OrderBy(e => e.Date) + .ToList(); + var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId)); + + var otherGrades = _grades.GetByGroup(_groupId) + .Where(g => InPeriod(g.Date, period)) + .ToList(); + var gradeColumnKeys = otherGrades + .Select(g => (g.Category, Note: g.Note ?? "", g.Date)) + .Distinct() + .OrderBy(k => k.Date) + .ToList(); + + Columns.Clear(); + foreach (var exam in exams) + Columns.Add(new GradeOverviewColumnDef($"{exam.Date:dd.MM.} {exam.Title}")); + foreach (var key in gradeColumnKeys) + { + var header = string.IsNullOrWhiteSpace(key.Note) + ? $"{GradeCategoryDisplay.Label(key.Category)} {key.Date:dd.MM.}" + : key.Note; + Columns.Add(new GradeOverviewColumnDef(header)); + } + + Rows.Clear(); + foreach (var student in students) + { + membershipsByStudent.TryGetValue(student.Id, out var membership); + if (!StudentActiveInPeriod(membership, period)) continue; + + var cells = new List(); + var numeric = new List<(string Grade, double Weight)>(); + + foreach (var exam in exams) + { + if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) { cells.Add(""); continue; } + resultsByExam[exam.Id].TryGetValue(student.Id, out var result); + if (result is null) { cells.Add(""); continue; } + if (result.Absent) { cells.Add("abwesend"); continue; } + var display = FormatValue(result.Grade); + cells.Add(display); + if (result.Grade is not null) numeric.Add((result.Grade, 1.0)); + } + + foreach (var key in gradeColumnKeys) + { + var grade = otherGrades.FirstOrDefault(g => + g.StudentId == student.Id && g.Category == key.Category && + (g.Note ?? "") == key.Note && g.Date == key.Date); + if (grade is null) { cells.Add(""); continue; } + cells.Add(FormatValue(grade.Value)); + numeric.Add((grade.Value, grade.Weight)); + } + + var total = numeric.Count == 0 ? (double?)null : _grading.WeightedAverage(numeric); + var totalDisplay = total is null ? "–" : total.Value.ToString("0.00", CultureInfo.InvariantCulture); + + Rows.Add(new GradeOverviewRow(student.Id, student.FullName, cells, totalDisplay, total)); + } + + ApplySort(); + RebuildColumnsSignal++; + } + + private string FormatValue(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return ""; + if (!ShowAsPoints && _gradingSystem == GradingSystem.Points0To15 && int.TryParse(raw, out var points)) + return PointsNoteMapping.PointsToNote(points); + return raw; + } + + private void ApplySort() + { + var sorted = _sortByTotal + ? Rows.OrderBy(r => r.TotalSortValue is null).ThenBy(r => r.TotalSortValue).ToList() + : Rows.OrderBy(r => r.Name).ToList(); + if (_sortDescending) sorted.Reverse(); + for (var i = 0; i < sorted.Count; i++) Rows.Move(Rows.IndexOf(sorted[i]), i); + } + + 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 static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period) + { + if (period == ParticipationPeriod.FullYear || m is null) return true; + return m.Period switch + { + MembershipPeriod.H1Only => period == ParticipationPeriod.H1, + MembershipPeriod.H2Only => period == ParticipationPeriod.H2, + _ => true, + }; + } +} + +public class GradeOverviewColumnDef(string header) +{ + public string Header { get; } = header; +} + +public class GradeOverviewRow(Guid studentId, string name, List cells, string totalDisplay, double? totalSortValue) +{ + public Guid StudentId { get; } = studentId; + public string Name { get; } = name; + public List Cells { get; } = cells; + public string TotalDisplay { get; } = totalDisplay; + public double? TotalSortValue { get; } = totalSortValue; +} + +// ── Kategorie-Anzeige & Punkte/Noten-Umrechnung ────────────────────────────── + +public static class GradeCategoryDisplay +{ + public static string Label(GradeCategory c) => c switch + { + GradeCategory.Oral => "Mündlich", + GradeCategory.Homework => "Hausaufgaben", + GradeCategory.Participation => "Mitarbeit", + GradeCategory.Project => "Projekt", + GradeCategory.Other => "Sonstiges", + _ => c.ToString(), + }; + + public static string[] Options { get; } = Enum.GetValues().Select(Label).ToArray(); + + public static GradeCategory FromLabel(string? label) => + Enum.GetValues().FirstOrDefault(c => Label(c) == label, GradeCategory.Other); +} + +public static class PointsNoteMapping +{ + // Grobe, standardübliche Punkte-Noten-Umrechnung (Oberstufe), nur für die Anzeige. + public static string PointsToNote(int points) => points switch + { + >= 13 => "1", + >= 10 => "2", + >= 7 => "3", + >= 4 => "4", + >= 1 => "5", + _ => "6", + }; +} + +// ── Note hinzufügen/bearbeiten für einen Schüler (2.2.1, 2.2.2) ────────────── + +public partial class StudentGradesDialogViewModel : ObservableObject +{ + private readonly IGradeRepository _grades; + private readonly Guid _studentId; + private readonly Guid _groupId; + + public string StudentName { get; } + + public ObservableCollection Entries { get; } = []; + + public StudentGradesDialogViewModel(IGradeRepository grades, Guid studentId, Guid groupId, string studentName) + { + _grades = grades; _studentId = studentId; _groupId = groupId; + StudentName = studentName; + Load(); + } + + private void Load() + { + Entries.Clear(); + foreach (var g in _grades.GetByStudentAndGroup(_studentId, _groupId).OrderByDescending(g => g.Date)) + Entries.Add(new GradeEditItem(g) { OnSave = Save, OnDelete = Delete }); + } + + [RelayCommand] + private void AddEntry() + { + var item = new GradeEditItem(new Grade + { + StudentId = _studentId, + GroupId = _groupId, + Category = GradeCategory.Other, + Date = DateOnly.FromDateTime(DateTime.Today), + }) + { OnSave = Save, OnDelete = Delete, IsNew = true }; + Entries.Insert(0, item); + } + + private void Save(GradeEditItem item) + { + if (string.IsNullOrWhiteSpace(item.Value)) { item.ValidationMessage = "Wert darf nicht leer sein."; return; } + if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null, + System.Globalization.DateTimeStyles.None, out _)) + { item.ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; } + item.ValidationMessage = ""; + var grade = item.ToModel(); + _grades.Save(grade); + item.IsNew = false; + item.MarkSaved(grade.CreatedAt); + } + + private void Delete(GradeEditItem item) + { + if (!item.IsNew) _grades.Delete(item.Id); + Entries.Remove(item); + } +} + +public partial class GradeEditItem : ObservableObject +{ + public Guid Id { get; } + private readonly Guid _studentId; + private readonly Guid _groupId; + public bool IsNew { get; set; } + + [ObservableProperty] private GradeCategory _category; + [ObservableProperty] private string _value; + [ObservableProperty] private string _dateText; + [ObservableProperty] private double _weight; + [ObservableProperty] private string? _note; + [ObservableProperty] private string _validationMessage = ""; + + // Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens. + public string CategoryName + { + get => GradeCategoryDisplay.Label(Category); + set => Category = GradeCategoryDisplay.FromLabel(value); + } + + public string CreatedAtDisplay { get; private set; } + + public Action? OnSave { get; set; } + public Action? OnDelete { get; set; } + + public GradeEditItem(Grade g) + { + Id = g.Id; + _studentId = g.StudentId; + _groupId = g.GroupId; + _category = g.Category; + _value = g.Value; + _dateText = g.Date.ToString("dd.MM.yyyy"); + _weight = g.Weight; + _note = g.Note; + CreatedAtDisplay = $"erfasst am {g.CreatedAt.ToLocalTime():dd.MM.yyyy HH:mm}"; + } + + public void MarkSaved(DateTime createdAt) => CreatedAtDisplay = $"erfasst am {createdAt.ToLocalTime():dd.MM.yyyy HH:mm}"; + + public Grade ToModel() => new() + { + Id = Id, + StudentId = _studentId, + GroupId = _groupId, + Category = Category, + Value = Value.Trim(), + Date = DateOnly.ParseExact(DateText, "dd.MM.yyyy"), + Weight = Weight, + Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(), + }; + + [RelayCommand] + private void Save() => OnSave?.Invoke(this); + + [RelayCommand] + private void Delete() => OnDelete?.Invoke(this); +} + +// ── Sammelerfassung für die ganze Gruppe (2.2.3) ───────────────────────────── + +public partial class CollectiveGradeDialogViewModel : ObservableObject +{ + private readonly IGradeRepository _grades; + private readonly Guid _groupId; + + [ObservableProperty] private GradeCategory _category = GradeCategory.Other; + [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); + [ObservableProperty] private double _weight = 1.0; + [ObservableProperty] private string? _note; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private string _validationMessage = ""; + + // Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens. + public string CategoryName + { + get => GradeCategoryDisplay.Label(Category); + set => Category = GradeCategoryDisplay.FromLabel(value); + } + + public ObservableCollection Rows { get; } = []; + + public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students, Guid groupId) + { + _grades = grades; _groupId = groupId; + foreach (var s in students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) + Rows.Add(new CollectiveGradeStudentRow(s.Id, s.FullName)); + } + + [RelayCommand] + private void SaveAll() + { + if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, + System.Globalization.DateTimeStyles.None, out var date)) + { ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; } + ValidationMessage = ""; + + var count = 0; + foreach (var row in Rows) + { + if (string.IsNullOrWhiteSpace(row.Value)) continue; + _grades.Save(new Grade + { + StudentId = row.StudentId, + GroupId = _groupId, + Category = Category, + Value = row.Value.Trim(), + Date = date, + Weight = Weight, + Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(), + }); + count++; + } + StatusMessage = count == 0 + ? "Keine Werte eingegeben." + : $"{count} Note(n) gespeichert."; + } +} + +public partial class CollectiveGradeStudentRow(Guid studentId, string name) : ObservableObject +{ + public Guid StudentId { get; } = studentId; + public string Name { get; } = name; + [ObservableProperty] private string _value = ""; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 240b180..337ba7c 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -182,6 +182,7 @@ public partial class GroupDetailViewModel : ObservableObject public ObservableCollection Exams { get; } = []; public ParticipationTabViewModel ParticipationTab { get; } + public GradeOverviewTabViewModel GradeOverviewTab { get; } public Func>? OnAddStudent { get; set; } public Func>? OnAddExam { get; set; } public Func>? OnEditExam { get; set; } @@ -193,11 +194,12 @@ public partial class GroupDetailViewModel : ObservableObject public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IGroupMembershipRepository memberships, ISubjectRepository subjects, IExamRepository exams, IGradeRepository grades, - ParticipationTabViewModel participationTab) + ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab) { _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _exams = exams; _grades = grades; ParticipationTab = participationTab; + GradeOverviewTab = gradeOverviewTab; } public void LoadGroup(Guid id) @@ -214,6 +216,7 @@ public partial class GroupDetailViewModel : ObservableObject LoadStudents(); ReloadExams(); ParticipationTab.Initialize(Group.Id, Group.SchoolYear); + GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle); } private void ReloadExams() diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index 4b7abdf..29167d4 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -28,6 +28,7 @@ public partial class ParticipationTabViewModel : ObservableObject public Guid GroupId => _groupId; public string SchoolYear => _schoolYear; public GradingSystem GradingSystem => _gradingSystem; + public string GroupLabel { get; private set; } = ""; [ObservableProperty] private ParticipationSessionItem? _selectedSession; [ObservableProperty] private string _noDataText = "Keine Bewertungssitzung ausgewählt."; @@ -47,6 +48,7 @@ public partial class ParticipationTabViewModel : ObservableObject public Func>? OnAddSession { get; set; } public Func? OnQuickInput { get; set; } public Func? OnComputeGrade { get; set; } + public Func? OnOpenWizard { get; set; } public ParticipationTabViewModel( IParticipationSessionRepository sessions, @@ -72,6 +74,7 @@ public partial class ParticipationTabViewModel : ObservableObject _subjectId = group?.SubjectId; _gradeLevel = group?.GradeLevel ?? 0; _gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6; + GroupLabel = group?.Name ?? ""; HasCompetencyCatalog = _subjectId.HasValue && _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel).Count > 0; @@ -143,6 +146,8 @@ public partial class ParticipationTabViewModel : ObservableObject var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes); row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val); row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val); + row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val); + row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val); StudentRows.Add(row); } QuickInputCommand.NotifyCanExecuteChanged(); @@ -185,6 +190,22 @@ public partial class ParticipationTabViewModel : ObservableObject if (SelectedSession is not null) LoadGrid(SelectedSession.Id); } + private void SaveHomework(Guid sessionId, Guid studentId, bool value) + { + var entry = _entries.GetBySessionAndStudent(sessionId, studentId) + ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; + entry.HomeworkMissing = value; + _entries.Save(entry); + } + + private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value) + { + var entry = _entries.GetBySessionAndStudent(sessionId, studentId) + ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; + entry.Attendance = value; + _entries.Save(entry); + } + [RelayCommand] private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible; @@ -259,6 +280,13 @@ public partial class ParticipationTabViewModel : ObservableObject await OnComputeGrade(this); } + [RelayCommand] + private async Task OpenWizard() + { + if (OnOpenWizard is null) return; + await OnOpenWizard(this); + } + [RelayCommand] private void DeleteSession() { @@ -310,8 +338,16 @@ public partial class ParticipationStudentRow : ObservableObject public ObservableCollection Cells { get; } = []; public ObservableCollection CompetencyCells { get; } = []; + [ObservableProperty] private bool _homeworkMissing; + [ObservableProperty] private AttendanceStatus? _attendance; + + public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance); + public string AttendanceTooltip => AttendanceDisplay.Label(Attendance); + public Action? OnRatingChanged { get; set; } public Action? OnCompetencyRatingChanged { get; set; } + public Action? HomeworkChangedCallback { get; set; } + public Action? AttendanceChangedCallback { get; set; } public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry, List aspects, List competencyCodes) @@ -320,6 +356,8 @@ public partial class ParticipationStudentRow : ObservableObject Name = name; _entry = entry; _aspectDefs = aspects; + _homeworkMissing = entry.HomeworkMissing; + _attendance = entry.Attendance; foreach (var a in aspects) { @@ -347,6 +385,61 @@ public partial class ParticipationStudentRow : ObservableObject cell?.SetValue(value); OnRatingChanged?.Invoke(StudentId, key, value); } + + [RelayCommand] + private void ToggleHomework() + { + HomeworkMissing = !HomeworkMissing; + HomeworkChangedCallback?.Invoke(StudentId, HomeworkMissing); + } + + [RelayCommand] + private void CycleAttendance() + { + Attendance = Attendance switch + { + null => AttendanceStatus.ExcusePending, + AttendanceStatus.ExcusePending => AttendanceStatus.Excused, + AttendanceStatus.Excused => AttendanceStatus.Unexcused, + AttendanceStatus.Unexcused => null, + _ => null, + }; + OnPropertyChanged(nameof(AttendanceLabel)); + OnPropertyChanged(nameof(AttendanceTooltip)); + AttendanceChangedCallback?.Invoke(StudentId, Attendance); + } + + // Direktes Setzen (z.B. aus dem Grading-Wizard heraus), ohne den Zyklus zu durchlaufen. + public void SetAttendance(AttendanceStatus? value) + { + Attendance = value; + OnPropertyChanged(nameof(AttendanceLabel)); + OnPropertyChanged(nameof(AttendanceTooltip)); + AttendanceChangedCallback?.Invoke(StudentId, value); + } +} + +// ── Anwesenheits-Anzeige ────────────────────────────────────────────────────── + +public static class AttendanceDisplay +{ + public static string Label(AttendanceStatus? s) => s switch + { + null => "Anwesend", + AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)", + AttendanceStatus.Excused => "Krank, entschuldigt", + AttendanceStatus.Unexcused => "Krank, unentschuldigt", + _ => "Anwesend", + }; + + public static string ShortLabel(AttendanceStatus? s) => s switch + { + null => "", + AttendanceStatus.ExcusePending => "K ?", + AttendanceStatus.Excused => "K ✓", + AttendanceStatus.Unexcused => "K ✗", + _ => "", + }; } // ── Eine Bewertungszelle ────────────────────────────────────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs new file mode 100644 index 0000000..3840546 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs @@ -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 _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 = ""; + + 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)) + .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(); + 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 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? 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); +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs new file mode 100644 index 0000000..1f0be7c --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs @@ -0,0 +1,254 @@ +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; +using System.Text; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Zeugnisnote (2.4) ───────────────────────────────────────────────────────── + +public partial class ReportGradeDialogViewModel : ObservableObject +{ + private readonly IGradeRepository _grades; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _results; + private readonly IStudentRepository _students; + private readonly IGroupMembershipRepository _memberships; + private readonly IGradingSchemeRepository _schemes; + private readonly IReportGradeRepository _reportGrades; + private readonly GradingService _grading; + private readonly Guid _groupId; + private readonly GroupType _groupType; + private readonly GradingSystem _gradingSystem; + private readonly string _groupLabel; + + [ObservableProperty] private ParticipationPeriodOption _selectedPeriod; + [ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial; + [ObservableProperty] private string _schemeSummary = ""; + + public string GroupLabel => _groupLabel; + public List PeriodOptions { get; } = + [ + new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"), + new(ParticipationPeriod.H1, "1. Halbjahr"), + new(ParticipationPeriod.H2, "2. Halbjahr"), + ]; + public string[] RoundingOptions { get; } = RoundingRuleDisplay.Options; + + // Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens. + public string RoundingRuleName + { + get => RoundingRuleDisplay.Label(RoundingRule); + set => RoundingRule = RoundingRuleDisplay.FromLabel(value); + } + + public ObservableCollection Rows { get; } = []; + + public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams, + IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships, + IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading, + Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel) + { + _grades = grades; _exams = exams; _results = results; _students = students; + _memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading; + _groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel; + + _selectedPeriod = PeriodOptions[0]; + Recompute(); + } + + partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute(); + partial void OnRoundingRuleChanged(RoundingRule value) => Recompute(); + + private GradingScheme ResolveScheme() => + _schemes.GetByGroup(_groupId) + ?? _schemes.GetDefaultForType(_groupType) + ?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }; + + private void Recompute() + { + var scheme = ResolveScheme(); + SchemeSummary = $"Klausuren {scheme.ExamsPercent:0.#} % · Mitarbeit {scheme.ParticipationPercent:0.#} % · " + + $"Sonstige {scheme.OtherPercent:0.#} %"; + + var period = SelectedPeriod.Period; + var periodTag = SelectedPeriod.Label; + + var students = _students.GetByGroup(_groupId); + var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId); + var exams = _exams.GetByGroup(_groupId).Where(e => InPeriod(e.Date, period)).ToList(); + var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId)); + var allGrades = _grades.GetByGroup(_groupId).Where(g => InPeriod(g.Date, period)).ToList(); + + Rows.Clear(); + foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) + { + membershipsByStudent.TryGetValue(student.Id, out var membership); + if (!StudentActiveInPeriod(membership, period)) continue; + + var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag); + + if (existing is { IsLocked: true }) + { + Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, Save, ToggleLock)); + continue; + } + + var examGrades = new List<(string Grade, double Weight)>(); + foreach (var exam in exams) + { + if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue; + if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue; + if (result.Absent || result.Grade is null) continue; + examGrades.Add((result.Grade, 1.0)); + } + + var participationGrades = allGrades + .Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation) + .Select(g => (g.Value, g.Weight)).ToList(); + var otherGrades = allGrades + .Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation) + .Select(g => (g.Value, g.Weight)).ToList(); + + var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades, + scheme, _gradingSystem, RoundingRule); + + Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, Save, ToggleLock)); + } + } + + private void Save(ReportGradeRow row) + { + if (!string.IsNullOrWhiteSpace(row.OverrideValue) && string.IsNullOrWhiteSpace(row.OverrideReason)) + { + row.ValidationMessage = "Für ein manuelles Übersteuern ist eine Begründung Pflicht."; + return; + } + row.ValidationMessage = ""; + + var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label) + ?? new ReportGrade { StudentId = row.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label }; + record.CalculatedValue = row.CalculatedValue ?? ""; + record.OverrideValue = string.IsNullOrWhiteSpace(row.OverrideValue) ? null : row.OverrideValue.Trim(); + record.OverrideReason = string.IsNullOrWhiteSpace(row.OverrideReason) ? null : row.OverrideReason.Trim(); + _reportGrades.Save(record); + row.MarkSaved(); + } + + private void ToggleLock(ReportGradeRow row) + { + var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label); + if (record is null) + { + if (!row.IsLocked) + { + // Festschreiben ohne vorherigen Save: aktuellen Stand zuerst sichern. + Save(row); + record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label); + if (record is null) return; + } + else return; + } + record.IsLocked = !record.IsLocked; + _reportGrades.Save(record); + Recompute(); + } + + public string ExportCsv() + { + var sb = new StringBuilder(); + sb.AppendLine($"Zeugnisnoten;{_groupLabel};{SelectedPeriod.Label}"); + sb.AppendLine("Schüler;Berechnet;Übersteuert;Begründung;Endnote;Gesperrt"); + foreach (var r in Rows) + sb.AppendLine($"{r.Name};{r.CalculatedValue};{r.OverrideValue};{r.OverrideReason};{r.FinalDisplay};{(r.IsLocked ? "ja" : "")}"); + return sb.ToString(); + } + + 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 static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period) + { + if (period == ParticipationPeriod.FullYear || m is null) return true; + return m.Period switch + { + MembershipPeriod.H1Only => period == ParticipationPeriod.H1, + MembershipPeriod.H2Only => period == ParticipationPeriod.H2, + _ => true, + }; + } +} + +// ── Rundungsregel-Anzeige ───────────────────────────────────────────────────── + +public static class RoundingRuleDisplay +{ + public static string Label(RoundingRule r) => r switch + { + RoundingRule.Commercial => "Kaufmännisch", + RoundingRule.Pedagogical => "Pädagogisch", + _ => r.ToString(), + }; + + public static string[] Options { get; } = [Label(RoundingRule.Commercial), Label(RoundingRule.Pedagogical)]; + + public static RoundingRule FromLabel(string? label) => + label == Label(RoundingRule.Pedagogical) ? RoundingRule.Pedagogical : RoundingRule.Commercial; +} + +// ── Zeile: Zeugnisnote eines Schülers ──────────────────────────────────────── + +public partial class ReportGradeRow : ObservableObject +{ + public Guid StudentId { get; } + public string Name { get; } + public string? CalculatedValue { get; private set; } + public bool IsLocked { get; private set; } + + [ObservableProperty] private string? _overrideValue; + [ObservableProperty] private string? _overrideReason; + [ObservableProperty] private string _validationMessage = ""; + + public string CalculatedDisplay => CalculatedValue ?? "–"; + public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay; + public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben"; + + public IRelayCommand SaveCommand { get; } + public IRelayCommand ToggleLockCommand { get; } + + private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing, + bool locked, Action onSave, Action onToggleLock) + { + StudentId = studentId; + Name = name; + CalculatedValue = calculated; + IsLocked = locked; + _overrideValue = existing?.OverrideValue; + _overrideReason = existing?.OverrideReason; + SaveCommand = new RelayCommand(() => onSave(this)); + ToggleLockCommand = new RelayCommand(() => onToggleLock(this)); + } + + public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated, + ReportGrade? existing, Action onSave, Action onToggleLock) => + new(studentId, name, calculated, existing, existing?.IsLocked ?? false, onSave, onToggleLock); + + public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked, + Action onSave, Action onToggleLock) => + new(studentId, name, locked.CalculatedValue, locked, true, onSave, onToggleLock); + + public void MarkSaved() + { + OnPropertyChanged(nameof(FinalDisplay)); + } + + partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay)); +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index ea5912f..2dea95a 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -16,6 +16,7 @@ public partial class SettingsViewModel : ObservableObject private readonly ISubjectRepository _subjects; private readonly ICompetencyDomainRepository _domainRepo; private readonly IGradingKeyTemplateRepository _gradingKeyTemplates; + private readonly IGradingSchemeRepository _gradingSchemes; private readonly GradingService _grading; // ── Fächer ──────────────────────────────────────────────────────────────── @@ -45,17 +46,39 @@ public partial class SettingsViewModel : ObservableObject public List GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"]; public ObservableCollection GradingKeyTemplateList { get; } = []; + // ── Gewichtungsschema-Voreinstellungen (2.3.3) ─────────────────────────── + + [ObservableProperty] private GradingSchemeEditItem _classScheme = null!; + [ObservableProperty] private GradingSchemeEditItem _courseScheme = null!; + // ── Konstruktor ─────────────────────────────────────────────────────────── public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, - IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading) + IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, + GradingService grading) { _subjects = subjects; _domainRepo = domainRepo; _gradingKeyTemplates = gradingKeyTemplates; + _gradingSchemes = gradingSchemes; _grading = grading; LoadSubjects(); LoadGradingKeyTemplates(); + LoadGradingSchemes(); + } + + // ── Gewichtungsschema-Voreinstellungen: Laden ──────────────────────────── + + private void LoadGradingSchemes() + { + ClassScheme = new GradingSchemeEditItem( + _gradingSchemes.GetDefaultForType(GroupType.Class) + ?? new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }, + "Klassen", _gradingSchemes, _grading); + CourseScheme = new GradingSchemeEditItem( + _gradingSchemes.GetDefaultForType(GroupType.Course) + ?? new GradingScheme { GroupType = GroupType.Course, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 }, + "Kurse", _gradingSchemes, _grading); } // ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ──────────────── @@ -407,6 +430,47 @@ public class GradingKeyEntryVm } } +// ── GradingSchemeEditItem (2.3) ──────────────────────────────────────────────── + +public partial class GradingSchemeEditItem : ObservableObject +{ + private readonly GradingScheme _scheme; + private readonly IGradingSchemeRepository _repo; + private readonly GradingService _grading; + + public string Label { get; } + + [ObservableProperty] private double _examsPercent; + [ObservableProperty] private double _participationPercent; + [ObservableProperty] private double _otherPercent; + [ObservableProperty] private string _validationMessage = ""; + [ObservableProperty] private string _statusMessage = ""; + + public GradingSchemeEditItem(GradingScheme scheme, string label, IGradingSchemeRepository repo, GradingService grading) + { + _scheme = scheme; _repo = repo; _grading = grading; + Label = label; + _examsPercent = scheme.ExamsPercent; + _participationPercent = scheme.ParticipationPercent; + _otherPercent = scheme.OtherPercent; + } + + [RelayCommand] + private void Save() + { + _scheme.ExamsPercent = ExamsPercent; + _scheme.ParticipationPercent = ParticipationPercent; + _scheme.OtherPercent = OtherPercent; + + var error = _grading.ValidateGradingScheme(_scheme); + if (error is not null) { ValidationMessage = error; StatusMessage = ""; return; } + + ValidationMessage = ""; + _repo.Save(_scheme); + StatusMessage = "Gespeichert."; + } +} + // ── Hilfklassen ─────────────────────────────────────────────────────────────── public class SubjectListItem(Subject s) diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index d22428a..0293ce5 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -2,7 +2,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; using System.Collections.ObjectModel; +using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Students; @@ -72,6 +74,9 @@ public partial class StudentDetailViewModel : ObservableObject private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; private readonly IDocumentationRepository _docs; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _examResults; + private readonly IGradeRepository _grades; [ObservableProperty] private Student? _student; [ObservableProperty] private string _studentTitle = ""; @@ -83,16 +88,19 @@ public partial class StudentDetailViewModel : ObservableObject public ObservableCollection GroupMemberships { get; } = []; public ObservableCollection Documentation { get; } = []; public ObservableCollection Contacts { get; } = []; + public ObservableCollection GradeHistory { get; } = []; public bool HasNoContacts => Contacts.Count == 0; public Func>? OnEditContact { get; set; } public Action? OnViewAddress { get; set; } public StudentDetailViewModel(IStudentRepository students, IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects, - IDocumentationRepository docs) + IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults, + IGradeRepository grades) { _students = students; _memberships = memberships; _groups = groups; _subjects = subjects; _docs = docs; + _exams = exams; _examResults = examResults; _grades = grades; } public void LoadStudent(Guid id) @@ -104,12 +112,16 @@ public partial class StudentDetailViewModel : ObservableObject EditLastName = Student.LastName; GroupMemberships.Clear(); + GradeHistory.Clear(); foreach (var membership in _memberships.GetByStudent(Student.Id)) { var g = _groups.GetById(membership.GroupId); if (g is null) continue; var subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : ""; GroupMemberships.Add(new() { SchoolYear = g.SchoolYear, GroupName = g.Name, Subject = subject }); + + var historyGroup = BuildGradeHistory(g, subject); + if (historyGroup is not null) GradeHistory.Add(historyGroup); } LoadContacts(); @@ -128,6 +140,49 @@ public partial class StudentDetailViewModel : ObservableObject IsConfidential = d.IsConfidential }); } + // ── Notenentwicklung (2.5) ──────────────────────────────────────────────── + + private StudentGradeHistoryGroup? BuildGradeHistory(LearningGroup g, string subject) + { + var label = string.IsNullOrEmpty(subject) ? g.Name : $"{g.Name} · {subject}"; + var entries = new List<(DateOnly Date, string PointLabel, string Value)>(); + + foreach (var exam in _exams.GetByGroup(g.Id)) + { + var result = _examResults.GetByExamAndStudent(exam.Id, Student!.Id); + if (result is null || result.Absent || result.Grade is null) continue; + entries.Add((exam.Date, exam.Title, result.Grade)); + } + foreach (var grade in _grades.GetByStudentAndGroup(Student!.Id, g.Id)) + entries.Add((grade.Date, GradeCategoryDisplay.Label(grade.Category), grade.Value)); + + var ordered = entries.OrderBy(e => e.Date).ToList(); + if (ordered.Count == 0) return null; + + var historyGroup = new StudentGradeHistoryGroup(label); + int? previousNoteEquivalent = null; + foreach (var e in ordered) + { + int? noteEquivalent = int.TryParse(e.Value, out var raw) + ? (g.GradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw))) + : null; + + var isFailing = noteEquivalent is >= 5; + var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue + && noteEquivalent.Value - previousNoteEquivalent.Value >= 1; + + var warnings = new List(); + if (isDrop) warnings.Add("Abfall um ≥ 1 Note"); + if (isFailing) warnings.Add("Versetzungsgefährdung"); + + historyGroup.Points.Add(new GradeHistoryPoint(e.Date, e.PointLabel, e.Value, + noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings))); + + if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent; + } + return historyGroup; + } + [RelayCommand] private void StartEdit() => IsEditing = true; [RelayCommand] private void CancelEdit() { @@ -200,6 +255,43 @@ public partial class StudentDetailViewModel : ObservableObject public class GroupMembershipEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; } public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } } +// ── Notenentwicklung (2.5) ────────────────────────────────────────────────── + +public class StudentGradeHistoryGroup(string label) +{ + public string Label { get; } = label; + public ObservableCollection Points { get; } = []; + public bool HasWarnings => Points.Any(p => p.IsWarning); +} + +public class GradeHistoryPoint +{ + public string DateDisplay { get; } + public string Label { get; } + public string Value { get; } + public double BarHeight { get; } + public bool IsWarning { get; } + public string WarningText { get; } + public string TooltipText { get; } + + public GradeHistoryPoint(DateOnly date, string label, string value, int? noteEquivalent, + bool isWarning, string warningText) + { + DateDisplay = date.ToString("dd.MM.", CultureInfo.InvariantCulture); + Label = label; + Value = value; + IsWarning = isWarning; + WarningText = warningText; + // Balkenhöhe nach Notenqualität (1 = beste Note) auf 6..60px, sonst neutrale Mindesthöhe. + BarHeight = noteEquivalent.HasValue + ? 6 + Math.Clamp((6 - noteEquivalent.Value) / 5.0, 0, 1) * 54 + : 6; + TooltipText = warningText.Length > 0 + ? $"{date:dd.MM.yyyy} · {label}: {value} ⚠ {warningText}" + : $"{date:dd.MM.yyyy} · {label}: {value}"; + } +} + public class ContactItem { public Contact Model { get; } diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index aae50e7..06a806a 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -167,6 +167,39 @@ + + + + + + + + + + + + + + + + +