From 4437f951d966f2b5f869cc69eb377b983877ec05 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 16 Aug 2026 02:32:12 +0200 Subject: [PATCH] Kompetenzen fast fertig --- .../Services/CompetencyAnalysisService.cs | 147 +++++++++++++++ .../CompetencyOverviewViewModelTests.cs | 68 +++++++ LehrerApp.Desktop.Tests/Fakes.cs | 3 +- .../GroupDetailViewModelTests.cs | 4 +- .../SettingsViewModelTests.cs | 83 ++++++++- LehrerApp.Desktop/AppBootstrapper.cs | 2 + .../Groups/CompetencyOverviewViewModels.cs | 174 ++++++++++++++++++ .../ViewModels/Groups/GroupViewModels.cs | 11 +- .../CompetencyCatalogCopyTargetViewModel.cs | 29 +++ .../CompetencyCatalogImportViewModel.cs | 5 +- .../ViewModels/Settings/SettingsViewModel.cs | 145 ++++++++++++++- .../Groups/CompetencyOverviewTabView.axaml | 68 +++++++ .../Groups/CompetencyOverviewTabView.axaml.cs | 8 + .../Views/Groups/GroupDetailView.axaml | 5 + .../Views/Groups/GroupListView.axaml | 7 +- .../CompetencyCatalogCopyTargetDialog.axaml | 29 +++ ...CompetencyCatalogCopyTargetDialog.axaml.cs | 18 ++ .../CompetencyCatalogImportDialog.axaml | 4 +- .../Views/Settings/SettingsView.axaml | 21 ++- .../Views/Settings/SettingsView.axaml.cs | 24 +++ .../CompetencyAnalysisServiceTests.cs | 132 +++++++++++++ TODO.md | 21 ++- 22 files changed, 979 insertions(+), 29 deletions(-) create mode 100644 LehrerApp.Core/Services/CompetencyAnalysisService.cs create mode 100644 LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs create mode 100644 LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogCopyTargetViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Settings/CompetencyCatalogCopyTargetDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Settings/CompetencyCatalogCopyTargetDialog.axaml.cs create mode 100644 LehrerApp.Tests/CompetencyAnalysisServiceTests.cs diff --git a/LehrerApp.Core/Services/CompetencyAnalysisService.cs b/LehrerApp.Core/Services/CompetencyAnalysisService.cs new file mode 100644 index 0000000..7dde444 --- /dev/null +++ b/LehrerApp.Core/Services/CompetencyAnalysisService.cs @@ -0,0 +1,147 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +public sealed record CompetencyScore( + double AchievedPoints, double MaxPoints, int EvidenceCount, int ParticipantCount) +{ + public double Percent => MaxPoints <= 0 ? 0 : AchievedPoints / MaxPoints * 100.0; +} + +public sealed class CompetencyAnalysisItem +{ + public required string DomainName { get; init; } + public required string DomainCode { get; init; } + public required string Code { get; init; } + public required string Description { get; init; } + public int PlannedUnitCount { get; init; } + public int TaughtUnitCount { get; init; } + public int PlannedExamCount { get; init; } + public int AssessedExamCount { get; init; } + public CompetencyScore? GroupScore { get; init; } + public IReadOnlyDictionary StudentScores { get; init; } = + new Dictionary(); +} + +public sealed class CompetencyAnalysisService +{ + public List Analyze( + IReadOnlyList catalog, + IReadOnlyList units, + IReadOnlyList exams, + IReadOnlyList results) + { + var builders = new List(); + var byCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var domain in catalog.OrderBy(x => x.SortOrder)) + foreach (var item in domain.Items.OrderBy(x => x.SortOrder)) + { + var builder = new ItemBuilder(domain.Name, domain.Code, item.Code, item.Description); + builders.Add(builder); + if (!string.IsNullOrWhiteSpace(item.Code)) byCode.TryAdd(item.Code.Trim(), builder); + } + + ItemBuilder GetOrAdd(string rawCode) + { + var code = rawCode.Trim(); + if (byCode.TryGetValue(code, out var existing)) return existing; + var added = new ItemBuilder("Nicht im Katalog", "", code, + "Dieser Kompetenzcode wird noch verwendet, ist aber nicht im aktuellen Katalog enthalten."); + byCode[code] = added; + builders.Add(added); + return added; + } + + foreach (var unit in units) + foreach (var code in NormalizeCodes(unit.Competencies)) + { + var builder = GetOrAdd(code); + if (unit.Status == UnitStatus.Planned) builder.PlannedUnits++; + else builder.TaughtUnits++; + } + + var resultsByExam = results.GroupBy(x => x.ExamId).ToDictionary(x => x.Key, x => x.ToList()); + foreach (var exam in exams) + { + var examCodes = NormalizeCodes(exam.Tasks.SelectMany(x => x.CompetencyCodes)); + foreach (var code in examCodes) + { + var builder = GetOrAdd(code); + if (exam.Status == ExamStatus.Planned) builder.PlannedExams++; + else builder.AssessedExams++; + } + + if (exam.Status is ExamStatus.Planned or ExamStatus.Conducted + || !resultsByExam.TryGetValue(exam.Id, out var examResults)) continue; + + foreach (var result in examResults.Where(x => !x.Absent)) + for (var taskIndex = 0; taskIndex < exam.Tasks.Count && taskIndex < result.Points.Count; taskIndex++) + { + var task = exam.Tasks[taskIndex]; + if (task.MaxPoints <= 0) continue; + var achieved = Math.Clamp(result.Points[taskIndex], 0, task.MaxPoints); + foreach (var code in NormalizeCodes(task.CompetencyCodes)) + GetOrAdd(code).AddScore(result.StudentId, achieved, task.MaxPoints); + } + } + + return builders.Select(x => x.Build()).ToList(); + } + + private static bool HasCode(string? value) => !string.IsNullOrWhiteSpace(value); + private static IEnumerable NormalizeCodes(IEnumerable codes) => + codes.Where(HasCode).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase); + + private sealed class ItemBuilder( + string domainName, string domainCode, string code, string description) + { + private readonly Dictionary _scores = []; + + public int PlannedUnits { get; set; } + public int TaughtUnits { get; set; } + public int PlannedExams { get; set; } + public int AssessedExams { get; set; } + + public void AddScore(Guid studentId, double achieved, double maximum) + { + if (!_scores.TryGetValue(studentId, out var score)) + _scores[studentId] = score = new MutableScore(); + score.Achieved += achieved; + score.Maximum += maximum; + score.EvidenceCount++; + } + + public CompetencyAnalysisItem Build() + { + var studentScores = _scores.ToDictionary(x => x.Key, x => x.Value.ToScore(1)); + var achieved = _scores.Values.Sum(x => x.Achieved); + var maximum = _scores.Values.Sum(x => x.Maximum); + var evidence = _scores.Values.Sum(x => x.EvidenceCount); + return new CompetencyAnalysisItem + { + DomainName = domainName, + DomainCode = domainCode, + Code = code, + Description = description, + PlannedUnitCount = PlannedUnits, + TaughtUnitCount = TaughtUnits, + PlannedExamCount = PlannedExams, + AssessedExamCount = AssessedExams, + GroupScore = maximum > 0 + ? new CompetencyScore(achieved, maximum, evidence, _scores.Count) + : null, + StudentScores = studentScores, + }; + } + } + + private sealed class MutableScore + { + public double Achieved { get; set; } + public double Maximum { get; set; } + public int EvidenceCount { get; set; } + public CompetencyScore ToScore(int participantCount) => + new(Achieved, Maximum, EvidenceCount, participantCount); + } +} diff --git a/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs new file mode 100644 index 0000000..4978bc9 --- /dev/null +++ b/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs @@ -0,0 +1,68 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class CompetencyOverviewViewModelTests +{ + [Fact] + public void Initialize_ZeigtAbdeckungGruppenmittelUndVorausgewaehltesSchuelerprofil() + { + var subjectId = Guid.NewGuid(); + var group = new LearningGroup { SubjectId = subjectId, GradeLevel = 8 }; + var student = new Student { FirstName = "Ada", LastName = "Lovelace" }; + var units = new FakeUnits(); + units.Add(new Unit + { + GroupId = group.Id, Status = UnitStatus.Completed, Competencies = ["K1"], + }); + var exam = new Exam + { + GroupId = group.Id, + Status = ExamStatus.Graded, + Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }], + }; + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = student.Id, Points = [5] }); + var catalog = new FakeCompetencyDomains(); + catalog.Add(new CompetencyDomain + { + SubjectId = subjectId, + GradeLevel = 8, + Name = "Bereich", + Items = [new CompetencyItem { Code = "K1", Description = "Kompetenz" }], + }); + var vm = new CompetencyOverviewTabViewModel(units, new FakeExams([exam]), results, + catalog, new FakeStudents([student]), new CompetencyAnalysisService()); + + vm.Initialize(group); + + var row = Assert.Single(vm.Rows); + Assert.Equal("1× behandelt", row.InstructionCoverage); + Assert.Equal("1× geprüft", row.ExamCoverage); + Assert.Contains("50 %", row.GroupResult); + Assert.Contains("50 %", row.StudentResult); + Assert.Equal("Wiederholungsbedarf", row.Recommendation); + Assert.Equal(student.Id, vm.SelectedStudent?.Id); + } + + [Fact] + public void ReviewThreshold_Aendern_AktualisiertHinweis() + { + var item = new CompetencyAnalysisItem + { + DomainName = "Bereich", + DomainCode = "B", + Code = "K1", + Description = "Kompetenz", + GroupScore = new CompetencyScore(5, 10, 1, 1), + }; + var row = new CompetencyOverviewRow(item, null, 60); + + row.SetThreshold(40); + + Assert.Equal("Stand solide", row.Recommendation); + } +} diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 42c89f9..fe01197 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -234,7 +234,8 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository private readonly List _all = []; public void Add(CompetencyDomain d) => _all.Add(d); public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => - _all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList(); + _all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) + .OrderBy(d => d.SortOrder).ToList(); public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id); public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); } public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs index beeec2f..9921f72 100644 --- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -22,7 +22,9 @@ public sealed class GroupDetailViewModelTests students, memberships, groups, new FakeCompetencyDomains()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, - new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService())); + new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()), + new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), + new FakeCompetencyDomains(), students, new CompetencyAnalysisService())); vm.LoadGroup(group.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 9933d0d..8d228b2 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -9,7 +9,8 @@ namespace LehrerApp.Desktop.Tests; public sealed class SettingsViewModelTests { private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, - FakeSupervisionDuties? supervisionDuties = null) + FakeSupervisionDuties? supervisionDuties = null, + FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -18,7 +19,7 @@ public sealed class SettingsViewModelTests Directory.CreateDirectory(tempPath); return new SettingsViewModel( - new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), + subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), @@ -219,4 +220,82 @@ public sealed class SettingsViewModelTests Assert.Empty(duties.GetAll()); Assert.Empty(vm.SupervisionDuties); } + + [Fact] + public void Kompetenzbereiche_Umsortieren_PersistiertSortOrder() + { + var subject = new Subject { Name = "Biologie", ShortName = "Bio" }; + var repository = new FakeCompetencyDomains(); + repository.Add(new CompetencyDomain + { + SubjectId = subject.Id, GradeLevel = 10, Name = "Erster Bereich", SortOrder = 0, + }); + repository.Add(new CompetencyDomain + { + SubjectId = subject.Id, GradeLevel = 10, Name = "Zweiter Bereich", SortOrder = 1, + }); + var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository); + vm.CatalogSubject = vm.Subjects.Single(); + + vm.Domains[1].MoveUpCommand.Execute(null); + + Assert.Equal(["Zweiter Bereich", "Erster Bereich"], vm.Domains.Select(x => x.Name)); + var stored = repository.GetBySubjectAndGrade(subject.Id, 10); + Assert.Equal(["Zweiter Bereich", "Erster Bereich"], stored.Select(x => x.Name)); + Assert.Equal([0, 1], stored.Select(x => x.SortOrder)); + } + + [Fact] + public void Kompetenzen_Umsortieren_PersistiertSortOrder() + { + var repository = new FakeCompetencyDomains(); + var domain = new CompetencyDomain + { + Name = "Bereich", + Items = + [ + new CompetencyItem { Code = "A", Description = "Erste", SortOrder = 0 }, + new CompetencyItem { Code = "B", Description = "Zweite", SortOrder = 1 }, + ], + }; + repository.Add(domain); + var vm = new DomainEditItem(domain, repository); + + vm.Items[1].MoveUpCommand.Execute(null); + + Assert.Equal(["B", "A"], vm.Items.Select(x => x.Code)); + var stored = repository.GetById(domain.Id)!; + Assert.Equal(["B", "A"], stored.Items.Select(x => x.Code)); + Assert.Equal([0, 1], stored.Items.Select(x => x.SortOrder)); + } + + [Fact] + public void KatalogKopieren_BereitetSichereVorschauFuerZielstufeVor() + { + var subject = new Subject { Name = "Biologie", ShortName = "Bio" }; + var repository = new FakeCompetencyDomains(); + repository.Add(new CompetencyDomain + { + SubjectId = subject.Id, + GradeLevel = 10, + Name = "Erkenntnisgewinnung", + Code = "EG", + Items = [new CompetencyItem { Code = "EG1", Description = "Modelle verwenden" }], + }); + var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository); + vm.CatalogSubject = vm.Subjects.Single(); + + var preview = vm.PrepareCatalogCopy(11); + + Assert.NotNull(preview); + Assert.Equal(11, preview.GradeLevel); + Assert.Equal(1, preview.NewDomains); + Assert.Equal(1, preview.NewCompetencies); + + vm.ApplyCatalogImport(preview, CompetencyCatalogImportMode.Merge, new HashSet()); + var copied = repository.GetBySubjectAndGrade(subject.Id, 11); + Assert.Single(copied); + Assert.NotEqual(repository.GetBySubjectAndGrade(subject.Id, 10)[0].Id, copied[0].Id); + Assert.Equal("EG1", copied[0].Items.Single().Code); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index d2de507..c3ebd51 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -146,6 +146,7 @@ public static class AppBootstrapper // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -214,6 +215,7 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs new file mode 100644 index 0000000..fe818c1 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/CompetencyOverviewViewModels.cs @@ -0,0 +1,174 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public partial class CompetencyOverviewTabViewModel : ObservableObject +{ + private readonly IUnitRepository _units; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _results; + private readonly ICompetencyDomainRepository _catalog; + private readonly IStudentRepository _students; + private readonly CompetencyAnalysisService _analysis; + private LearningGroup? _group; + + [ObservableProperty] private CompetencyStudentOption? _selectedStudent; + [ObservableProperty] private double _reviewThreshold = 60; + [ObservableProperty] private string _summary = ""; + [ObservableProperty] private string _emptyMessage = ""; + + public ObservableCollection StudentOptions { get; } = []; + public ObservableCollection Rows { get; } = []; + public bool HasRows => Rows.Count > 0; + public bool HasStudents => StudentOptions.Count > 0; + public string ThresholdLabel => $"Wiederholungsbedarf unter {ReviewThreshold:0} %"; + + public CompetencyOverviewTabViewModel(IUnitRepository units, IExamRepository exams, + IExamResultRepository results, ICompetencyDomainRepository catalog, + IStudentRepository students, CompetencyAnalysisService analysis) + { + _units = units; + _exams = exams; + _results = results; + _catalog = catalog; + _students = students; + _analysis = analysis; + } + + public void Initialize(LearningGroup group) + { + _group = group; + Refresh(); + } + + [RelayCommand] + public void Refresh() + { + if (_group is null) return; + var selectedStudentId = SelectedStudent?.Id; + var domains = _group.SubjectId is Guid subjectId + ? _catalog.GetBySubjectAndGrade(subjectId, _group.GradeLevel) + : []; + var units = _units.GetByGroup(_group.Id); + var exams = _exams.GetByGroup(_group.Id); + var results = exams.SelectMany(x => _results.GetByExam(x.Id)).ToList(); + var analysis = _analysis.Analyze(domains, units, exams, results); + + StudentOptions.Clear(); + foreach (var student in _students.GetByGroup(_group.Id) + .OrderBy(x => x.LastName).ThenBy(x => x.FirstName)) + StudentOptions.Add(new CompetencyStudentOption(student.Id, student.FullName)); + SelectedStudent = StudentOptions.FirstOrDefault(x => x.Id == selectedStudentId) + ?? StudentOptions.FirstOrDefault(); + + Rows.Clear(); + foreach (var item in analysis) + Rows.Add(new CompetencyOverviewRow(item, SelectedStudent?.Id, ReviewThreshold)); + + var treated = analysis.Count(x => x.TaughtUnitCount > 0); + var assessed = analysis.Count(x => x.AssessedExamCount > 0); + var evaluated = analysis.Count(x => x.GroupScore is not null); + Summary = $"{treated} von {analysis.Count} behandelt · {assessed} geprüft · {evaluated} mit Ergebnisdaten"; + EmptyMessage = analysis.Count == 0 + ? "Noch keine Kompetenzen im Katalog oder in Unterrichtseinheiten und Klausuren zugeordnet." + : ""; + OnPropertyChanged(nameof(HasRows)); + OnPropertyChanged(nameof(HasStudents)); + } + + partial void OnSelectedStudentChanged(CompetencyStudentOption? value) + { + foreach (var row in Rows) row.SetStudent(value?.Id); + } + + partial void OnReviewThresholdChanged(double value) + { + OnPropertyChanged(nameof(ThresholdLabel)); + foreach (var row in Rows) row.SetThreshold(value); + } +} + +public sealed record CompetencyStudentOption(Guid Id, string DisplayName); + +public partial class CompetencyOverviewRow : ObservableObject +{ + private readonly CompetencyAnalysisItem _item; + private double _threshold; + + [ObservableProperty] private string _studentResult = "–"; + [ObservableProperty] private string _recommendation = "Noch keine Bewertung"; + [ObservableProperty] private string _recommendationColor = "#6B7280"; + + public string Domain => string.IsNullOrWhiteSpace(_item.DomainCode) + ? _item.DomainName + : $"{_item.DomainName} ({_item.DomainCode})"; + public string Code => string.IsNullOrWhiteSpace(_item.Code) ? "–" : _item.Code; + public string Description => _item.Description; + public string InstructionCoverage => CoverageText( + _item.TaughtUnitCount, "behandelt", _item.PlannedUnitCount, "geplant"); + public string ExamCoverage => CoverageText( + _item.AssessedExamCount, "geprüft", _item.PlannedExamCount, "geplant"); + public string GroupResult => ScoreText(_item.GroupScore, includeParticipants: true); + + public CompetencyOverviewRow(CompetencyAnalysisItem item, Guid? studentId, double threshold) + { + _item = item; + _threshold = threshold; + SetStudent(studentId); + UpdateRecommendation(); + } + + public void SetStudent(Guid? studentId) + { + StudentResult = studentId is Guid id && _item.StudentScores.TryGetValue(id, out var score) + ? ScoreText(score, includeParticipants: false) + : "–"; + } + + public void SetThreshold(double threshold) + { + _threshold = threshold; + UpdateRecommendation(); + } + + private void UpdateRecommendation() + { + if (_item.GroupScore is null) + { + Recommendation = "Noch keine Bewertung"; + RecommendationColor = "#6B7280"; + } + else if (_item.GroupScore.Percent < _threshold) + { + Recommendation = "Wiederholungsbedarf"; + RecommendationColor = "#C62828"; + } + else + { + Recommendation = "Stand solide"; + RecommendationColor = "#2E7D32"; + } + } + + private static string CoverageText(int completed, string completedLabel, int planned, string plannedLabel) + { + if (completed == 0 && planned == 0) return "–"; + if (completed == 0) return $"{planned}× {plannedLabel}"; + if (planned == 0) return $"{completed}× {completedLabel}"; + return $"{completed}× {completedLabel} · {planned}× {plannedLabel}"; + } + + private static string ScoreText(CompetencyScore? score, bool includeParticipants) + { + if (score is null) return "–"; + var evidence = score.EvidenceCount == 1 ? "1 Aufgabenwert" : $"{score.EvidenceCount} Aufgabenwerte"; + return includeParticipants + ? $"{score.Percent:0.#} % · {score.ParticipantCount} Schüler · {evidence}" + : $"{score.Percent:0.#} % · {evidence}"; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index d551b51..636947c 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -206,6 +206,10 @@ public partial class GroupDetailViewModel : ObservableObject NotifyWriteCommands(); } partial void OnShowFormerStudentsChanged(bool value) => LoadStudents(); + partial void OnActiveTabIndexChanged(int value) + { + if (value == 6) CompetencyOverviewTab.Refresh(); + } public ObservableCollection Students { get; } = []; public ObservableCollection Exams { get; } = []; @@ -213,6 +217,7 @@ public partial class GroupDetailViewModel : ObservableObject public ParticipationTabViewModel ParticipationTab { get; } public GradeOverviewTabViewModel GradeOverviewTab { get; } public PlanningTabViewModel PlanningTab { get; } + public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; } public Func>? OnAddStudent { get; set; } public Func>? OnWithdrawStudent { get; set; } public Func>? OnAddExam { get; set; } @@ -227,13 +232,14 @@ public partial class GroupDetailViewModel : ObservableObject IGroupMembershipRepository memberships, ISubjectRepository subjects, IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, - PlanningTabViewModel planningTab) + PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab) { _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _exams = exams; _grades = grades; _tasks = tasks; ParticipationTab = participationTab; GradeOverviewTab = gradeOverviewTab; PlanningTab = planningTab; + CompetencyOverviewTab = competencyOverviewTab; } public void LoadGroup(Guid id) @@ -252,6 +258,7 @@ public partial class GroupDetailViewModel : ObservableObject ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly); GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly); PlanningTab.Initialize(Group.Id, IsReadOnly); + CompetencyOverviewTab.Initialize(Group); } private void ReloadExams() @@ -261,6 +268,7 @@ public partial class GroupDetailViewModel : ObservableObject Exams.Clear(); foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e)); SelectedExam = Exams.FirstOrDefault(e => e.Id == selectedId); + CompetencyOverviewTab.Refresh(); } public void LoadStudents() @@ -368,6 +376,7 @@ public partial class GroupDetailViewModel : ObservableObject var exam = _exams.GetById(SelectedExam.Id); if (exam is null) return; await OnGradeExam(exam); + CompetencyOverviewTab.Refresh(); } [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] diff --git a/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogCopyTargetViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogCopyTargetViewModel.cs new file mode 100644 index 0000000..65466a4 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogCopyTargetViewModel.cs @@ -0,0 +1,29 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace LehrerApp.Desktop.ViewModels.Settings; + +public partial class CompetencyCatalogCopyTargetViewModel : ObservableObject +{ + [ObservableProperty] private int _targetGradeLevel; + [ObservableProperty] private string _error = ""; + + public string SourceLabel { get; } + public int SourceGradeLevel { get; } + + public CompetencyCatalogCopyTargetViewModel(string subjectName, int sourceGradeLevel) + { + SourceGradeLevel = sourceGradeLevel; + SourceLabel = $"{subjectName} · Klassenstufe {sourceGradeLevel}"; + _targetGradeLevel = sourceGradeLevel < 13 ? sourceGradeLevel + 1 : sourceGradeLevel - 1; + } + + public bool Validate() + { + Error = TargetGradeLevel == SourceGradeLevel + ? "Quell- und Zielklassenstufe müssen verschieden sein." + : TargetGradeLevel is < 1 or > 13 + ? "Die Klassenstufe muss zwischen 1 und 13 liegen." + : ""; + return Error.Length == 0; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogImportViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogImportViewModel.cs index 319f1fd..305df2e 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogImportViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogImportViewModel.cs @@ -16,6 +16,7 @@ public partial class CompetencyCatalogImportViewModel : ObservableObject public IReadOnlyList Modes { get; } public ObservableCollection Conflicts { get; } = []; public IReadOnlyList Warnings => _preview.Warnings; + public string Heading { get; } public string Target => $"{_preview.SubjectName} · Klassenstufe {_preview.GradeLevel}"; public string Summary => $"{_preview.NewDomains} neue Bereiche · {_preview.NewCompetencies} neue Kompetenzen · " + @@ -26,10 +27,12 @@ public partial class CompetencyCatalogImportViewModel : ObservableObject public bool IsReplace => SelectedMode.Mode == CompetencyCatalogImportMode.Replace; public CompetencyCatalogImportViewModel( - SettingsViewModel settings, CompetencyCatalogImportPreview preview) + SettingsViewModel settings, CompetencyCatalogImportPreview preview, + string heading = "Import prüfen") { _settings = settings; _preview = preview; + Heading = heading; Modes = [ new(CompetencyCatalogImportMode.Merge, "Zusammenführen (empfohlen)"), diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index fc7784d..8e7a88f 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -733,7 +733,8 @@ public partial class SettingsViewModel : ObservableObject CatalogValidation = ""; if (CatalogSubject is null) return; foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel)) - Domains.Add(new DomainEditItem(d, _domainRepo)); + Domains.Add(CreateDomainEditItem(d)); + RefreshDomainMoveState(); } // ── Katalog: Bereich hinzufügen / löschen ──────────────────────────────── @@ -753,7 +754,8 @@ public partial class SettingsViewModel : ObservableObject SortOrder = Domains.Count, }; _domainRepo.Save(domain); - Domains.Add(new DomainEditItem(domain, _domainRepo)); + Domains.Add(CreateDomainEditItem(domain)); + RefreshDomainMoveState(); NewDomainName = ""; NewDomainCode = ""; CatalogValidation = ""; } @@ -763,6 +765,31 @@ public partial class SettingsViewModel : ObservableObject if (item is null) return; _domainRepo.Delete(item.Id); Domains.Remove(item); + PersistDomainOrder(); + } + + private DomainEditItem CreateDomainEditItem(CompetencyDomain domain) => + new(domain, _domainRepo, item => MoveDomain(item, -1), item => MoveDomain(item, 1)); + + private void MoveDomain(DomainEditItem item, int offset) + { + var oldIndex = Domains.IndexOf(item); + var newIndex = oldIndex + offset; + if (oldIndex < 0 || newIndex < 0 || newIndex >= Domains.Count) return; + Domains.Move(oldIndex, newIndex); + PersistDomainOrder(); + } + + private void PersistDomainOrder() + { + for (var i = 0; i < Domains.Count; i++) Domains[i].SetSortOrder(i); + RefreshDomainMoveState(); + } + + private void RefreshDomainMoveState() + { + for (var i = 0; i < Domains.Count; i++) + Domains[i].SetMoveState(i > 0, i < Domains.Count - 1); } // ── JSON Import / Export ────────────────────────────────────────────────── @@ -798,11 +825,40 @@ public partial class SettingsViewModel : ObservableObject } public string ExportCatalog() + => SerializeCatalog(CatalogGradeLevel); + + public CompetencyCatalogImportPreview? PrepareCatalogCopy(int targetGradeLevel) + { + if (CatalogSubject is null) + { + CatalogValidation = "Bitte zuerst ein Fach auswählen."; + return null; + } + if (Domains.Count == 0) + { + CatalogValidation = "Der ausgewählte Katalog enthält keine Bereiche."; + return null; + } + if (targetGradeLevel is < 1 or > 13 || targetGradeLevel == CatalogGradeLevel) + { + CatalogValidation = "Bitte eine andere Zielklassenstufe zwischen 1 und 13 auswählen."; + return null; + } + + CatalogValidation = ""; + return _catalogImport.Analyze(SerializeCatalog(targetGradeLevel), CatalogSubject.Id, + CatalogSubject.Name, targetGradeLevel); + } + + public void SetCatalogCopyStatus(int targetGradeLevel) => + CatalogValidation = $"Kompetenzkatalog wurde in Klassenstufe {targetGradeLevel} kopiert."; + + private string SerializeCatalog(int gradeLevel) { var dto = new CatalogDto { Subject = CatalogSubject?.Name ?? "", - GradeLevel = CatalogGradeLevel, + GradeLevel = gradeLevel, Domains = Domains.Select(d => new DomainDto { Name = d.Name, @@ -834,8 +890,11 @@ public partial class DomainEditItem : ObservableObject [ObservableProperty] private string _newItemDesc = ""; public ObservableCollection Items { get; } = []; + public IRelayCommand MoveUpCommand { get; } + public IRelayCommand MoveDownCommand { get; } - public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo) + public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo, + Action? onMoveUp = null, Action? onMoveDown = null) { _domain = domain; _repo = repo; @@ -846,8 +905,31 @@ public partial class DomainEditItem : ObservableObject ? domain.Name : $"{domain.Name} ({domain.Code})"; - foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) - Items.Add(new CompetencyItemVm(item, DeleteItem)); + MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp); + MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown); + + _domain.Items = domain.Items.OrderBy(i => i.SortOrder).ToList(); + foreach (var item in _domain.Items) + Items.Add(CreateItemViewModel(item)); + RefreshItemMoveState(); + } + + private bool _canMoveUp; + private bool _canMoveDown; + + internal void SetMoveState(bool canMoveUp, bool canMoveDown) + { + _canMoveUp = canMoveUp; + _canMoveDown = canMoveDown; + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + } + + internal void SetSortOrder(int sortOrder) + { + if (_domain.SortOrder == sortOrder) return; + _domain.SortOrder = sortOrder; + _repo.Save(_domain); } [RelayCommand] @@ -862,15 +944,42 @@ public partial class DomainEditItem : ObservableObject }; _domain.Items.Add(item); _repo.Save(_domain); - Items.Add(new CompetencyItemVm(item, DeleteItem)); + Items.Add(CreateItemViewModel(item)); + RefreshItemMoveState(); NewItemCode = ""; NewItemDesc = ""; } private void DeleteItem(CompetencyItemVm vm) { _domain.Items.RemoveAll(i => i.Id == vm.ItemId); - _repo.Save(_domain); Items.Remove(vm); + PersistItemOrder(); + } + + private CompetencyItemVm CreateItemViewModel(CompetencyItem item) => + new(item, DeleteItem, vm => MoveItem(vm, -1), vm => MoveItem(vm, 1)); + + private void MoveItem(CompetencyItemVm item, int offset) + { + var oldIndex = Items.IndexOf(item); + var newIndex = oldIndex + offset; + if (oldIndex < 0 || newIndex < 0 || newIndex >= Items.Count) return; + Items.Move(oldIndex, newIndex); + PersistItemOrder(); + } + + private void PersistItemOrder() + { + _domain.Items = Items.Select(x => x.Model).ToList(); + for (var i = 0; i < _domain.Items.Count; i++) _domain.Items[i].SortOrder = i; + _repo.Save(_domain); + RefreshItemMoveState(); + } + + private void RefreshItemMoveState() + { + for (var i = 0; i < Items.Count; i++) + Items[i].SetMoveState(i > 0, i < Items.Count - 1); } } @@ -878,14 +987,19 @@ public partial class DomainEditItem : ObservableObject public class CompetencyItemVm { + internal CompetencyItem Model { get; } public Guid ItemId { get; } public string Code { get; } public string Description { get; } public string Display { get; } public IRelayCommand DeleteCommand { get; } + public IRelayCommand MoveUpCommand { get; } + public IRelayCommand MoveDownCommand { get; } - public CompetencyItemVm(CompetencyItem item, Action onDelete) + public CompetencyItemVm(CompetencyItem item, Action onDelete, + Action? onMoveUp = null, Action? onMoveDown = null) { + Model = item; ItemId = item.Id; Code = item.Code; Description = item.Description; @@ -893,6 +1007,19 @@ public class CompetencyItemVm ? item.Description : $"[{item.Code}] {item.Description}"; DeleteCommand = new RelayCommand(() => onDelete(this)); + MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp); + MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown); + } + + private bool _canMoveUp; + private bool _canMoveDown; + + internal void SetMoveState(bool canMoveUp, bool canMoveDown) + { + _canMoveUp = canMoveUp; + _canMoveDown = canMoveDown; + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); } } diff --git a/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml new file mode 100644 index 0000000..952f054 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/CompetencyOverviewTabView.axaml @@ -0,0 +1,68 @@ + + + + + + + + +