Kompetenzen fast fertig

This commit is contained in:
2026-08-16 02:32:12 +02:00
parent 7c7fe6dfcb
commit 4437f951d9
22 changed files with 979 additions and 29 deletions
@@ -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<Guid, CompetencyScore> StudentScores { get; init; } =
new Dictionary<Guid, CompetencyScore>();
}
public sealed class CompetencyAnalysisService
{
public List<CompetencyAnalysisItem> Analyze(
IReadOnlyList<CompetencyDomain> catalog,
IReadOnlyList<Unit> units,
IReadOnlyList<Exam> exams,
IReadOnlyList<ExamResult> results)
{
var builders = new List<ItemBuilder>();
var byCode = new Dictionary<string, ItemBuilder>(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<string> NormalizeCodes(IEnumerable<string> 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<Guid, MutableScore> _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);
}
}
@@ -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);
}
}
+2 -1
View File
@@ -234,7 +234,8 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
private readonly List<CompetencyDomain> _all = []; private readonly List<CompetencyDomain> _all = [];
public void Add(CompetencyDomain d) => _all.Add(d); public void Add(CompetencyDomain d) => _all.Add(d);
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> 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 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 Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
@@ -22,7 +22,9 @@ public sealed class GroupDetailViewModelTests
students, memberships, groups, new FakeCompetencyDomains()), students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, 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.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -9,7 +9,8 @@ namespace LehrerApp.Desktop.Tests;
public sealed class SettingsViewModelTests public sealed class SettingsViewModelTests
{ {
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, 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, // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -18,7 +19,7 @@ public sealed class SettingsViewModelTests
Directory.CreateDirectory(tempPath); Directory.CreateDirectory(tempPath);
return new SettingsViewModel( 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 FakeSchemes(), new GradingService(), new BackupService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
@@ -219,4 +220,82 @@ public sealed class SettingsViewModelTests
Assert.Empty(duties.GetAll()); Assert.Empty(duties.GetAll());
Assert.Empty(vm.SupervisionDuties); 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<string>());
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);
}
} }
+2
View File
@@ -146,6 +146,7 @@ public static class AppBootstrapper
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
services.AddSingleton<CompetencyAnalysisService>();
services.AddSingleton<SchoolYearService>(); services.AddSingleton<SchoolYearService>();
services.AddSingleton<GroupRolloverService>(); services.AddSingleton<GroupRolloverService>();
services.AddSingleton<PublicHolidayService>(); services.AddSingleton<PublicHolidayService>();
@@ -214,6 +215,7 @@ public static class AppBootstrapper
services.AddTransient<ParticipationTabViewModel>(); services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>(); services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<PlanningTabViewModel>(); services.AddTransient<PlanningTabViewModel>();
services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
@@ -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<CompetencyStudentOption> StudentOptions { get; } = [];
public ObservableCollection<CompetencyOverviewRow> 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}";
}
}
@@ -206,6 +206,10 @@ public partial class GroupDetailViewModel : ObservableObject
NotifyWriteCommands(); NotifyWriteCommands();
} }
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents(); partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
partial void OnActiveTabIndexChanged(int value)
{
if (value == 6) CompetencyOverviewTab.Refresh();
}
public ObservableCollection<StudentSummary> Students { get; } = []; public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = []; public ObservableCollection<ExamSummary> Exams { get; } = [];
@@ -213,6 +217,7 @@ public partial class GroupDetailViewModel : ObservableObject
public ParticipationTabViewModel ParticipationTab { get; } public ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; } public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; } public PlanningTabViewModel PlanningTab { get; }
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; } public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; } public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; } public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -227,13 +232,14 @@ public partial class GroupDetailViewModel : ObservableObject
IGroupMembershipRepository memberships, ISubjectRepository subjects, IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab) PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab)
{ {
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks; _exams = exams; _grades = grades; _tasks = tasks;
ParticipationTab = participationTab; ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab; GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab; PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab;
} }
public void LoadGroup(Guid id) public void LoadGroup(Guid id)
@@ -252,6 +258,7 @@ public partial class GroupDetailViewModel : ObservableObject
ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly); ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly); GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
PlanningTab.Initialize(Group.Id, IsReadOnly); PlanningTab.Initialize(Group.Id, IsReadOnly);
CompetencyOverviewTab.Initialize(Group);
} }
private void ReloadExams() private void ReloadExams()
@@ -261,6 +268,7 @@ public partial class GroupDetailViewModel : ObservableObject
Exams.Clear(); Exams.Clear();
foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e)); foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e));
SelectedExam = Exams.FirstOrDefault(e => e.Id == selectedId); SelectedExam = Exams.FirstOrDefault(e => e.Id == selectedId);
CompetencyOverviewTab.Refresh();
} }
public void LoadStudents() public void LoadStudents()
@@ -368,6 +376,7 @@ public partial class GroupDetailViewModel : ObservableObject
var exam = _exams.GetById(SelectedExam.Id); var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return; if (exam is null) return;
await OnGradeExam(exam); await OnGradeExam(exam);
CompetencyOverviewTab.Refresh();
} }
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))] [RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
@@ -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;
}
}
@@ -16,6 +16,7 @@ public partial class CompetencyCatalogImportViewModel : ObservableObject
public IReadOnlyList<ImportModeOption> Modes { get; } public IReadOnlyList<ImportModeOption> Modes { get; }
public ObservableCollection<CompetencyImportConflictItem> Conflicts { get; } = []; public ObservableCollection<CompetencyImportConflictItem> Conflicts { get; } = [];
public IReadOnlyList<string> Warnings => _preview.Warnings; public IReadOnlyList<string> Warnings => _preview.Warnings;
public string Heading { get; }
public string Target => $"{_preview.SubjectName} · Klassenstufe {_preview.GradeLevel}"; public string Target => $"{_preview.SubjectName} · Klassenstufe {_preview.GradeLevel}";
public string Summary => public string Summary =>
$"{_preview.NewDomains} neue Bereiche · {_preview.NewCompetencies} neue Kompetenzen · " + $"{_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 bool IsReplace => SelectedMode.Mode == CompetencyCatalogImportMode.Replace;
public CompetencyCatalogImportViewModel( public CompetencyCatalogImportViewModel(
SettingsViewModel settings, CompetencyCatalogImportPreview preview) SettingsViewModel settings, CompetencyCatalogImportPreview preview,
string heading = "Import prüfen")
{ {
_settings = settings; _settings = settings;
_preview = preview; _preview = preview;
Heading = heading;
Modes = Modes =
[ [
new(CompetencyCatalogImportMode.Merge, "Zusammenführen (empfohlen)"), new(CompetencyCatalogImportMode.Merge, "Zusammenführen (empfohlen)"),
@@ -733,7 +733,8 @@ public partial class SettingsViewModel : ObservableObject
CatalogValidation = ""; CatalogValidation = "";
if (CatalogSubject is null) return; if (CatalogSubject is null) return;
foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel)) 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 ──────────────────────────────── // ── Katalog: Bereich hinzufügen / löschen ────────────────────────────────
@@ -753,7 +754,8 @@ public partial class SettingsViewModel : ObservableObject
SortOrder = Domains.Count, SortOrder = Domains.Count,
}; };
_domainRepo.Save(domain); _domainRepo.Save(domain);
Domains.Add(new DomainEditItem(domain, _domainRepo)); Domains.Add(CreateDomainEditItem(domain));
RefreshDomainMoveState();
NewDomainName = ""; NewDomainCode = ""; CatalogValidation = ""; NewDomainName = ""; NewDomainCode = ""; CatalogValidation = "";
} }
@@ -763,6 +765,31 @@ public partial class SettingsViewModel : ObservableObject
if (item is null) return; if (item is null) return;
_domainRepo.Delete(item.Id); _domainRepo.Delete(item.Id);
Domains.Remove(item); 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 ────────────────────────────────────────────────── // ── JSON Import / Export ──────────────────────────────────────────────────
@@ -798,11 +825,40 @@ public partial class SettingsViewModel : ObservableObject
} }
public string ExportCatalog() 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 var dto = new CatalogDto
{ {
Subject = CatalogSubject?.Name ?? "", Subject = CatalogSubject?.Name ?? "",
GradeLevel = CatalogGradeLevel, GradeLevel = gradeLevel,
Domains = Domains.Select(d => new DomainDto Domains = Domains.Select(d => new DomainDto
{ {
Name = d.Name, Name = d.Name,
@@ -834,8 +890,11 @@ public partial class DomainEditItem : ObservableObject
[ObservableProperty] private string _newItemDesc = ""; [ObservableProperty] private string _newItemDesc = "";
public ObservableCollection<CompetencyItemVm> Items { get; } = []; public ObservableCollection<CompetencyItemVm> Items { get; } = [];
public IRelayCommand MoveUpCommand { get; }
public IRelayCommand MoveDownCommand { get; }
public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo) public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo,
Action<DomainEditItem>? onMoveUp = null, Action<DomainEditItem>? onMoveDown = null)
{ {
_domain = domain; _domain = domain;
_repo = repo; _repo = repo;
@@ -846,8 +905,31 @@ public partial class DomainEditItem : ObservableObject
? domain.Name ? domain.Name
: $"{domain.Name} ({domain.Code})"; : $"{domain.Name} ({domain.Code})";
foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
Items.Add(new CompetencyItemVm(item, DeleteItem)); 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] [RelayCommand]
@@ -862,15 +944,42 @@ public partial class DomainEditItem : ObservableObject
}; };
_domain.Items.Add(item); _domain.Items.Add(item);
_repo.Save(_domain); _repo.Save(_domain);
Items.Add(new CompetencyItemVm(item, DeleteItem)); Items.Add(CreateItemViewModel(item));
RefreshItemMoveState();
NewItemCode = ""; NewItemDesc = ""; NewItemCode = ""; NewItemDesc = "";
} }
private void DeleteItem(CompetencyItemVm vm) private void DeleteItem(CompetencyItemVm vm)
{ {
_domain.Items.RemoveAll(i => i.Id == vm.ItemId); _domain.Items.RemoveAll(i => i.Id == vm.ItemId);
_repo.Save(_domain);
Items.Remove(vm); 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 public class CompetencyItemVm
{ {
internal CompetencyItem Model { get; }
public Guid ItemId { get; } public Guid ItemId { get; }
public string Code { get; } public string Code { get; }
public string Description { get; } public string Description { get; }
public string Display { get; } public string Display { get; }
public IRelayCommand DeleteCommand { get; } public IRelayCommand DeleteCommand { get; }
public IRelayCommand MoveUpCommand { get; }
public IRelayCommand MoveDownCommand { get; }
public CompetencyItemVm(CompetencyItem item, Action<CompetencyItemVm> onDelete) public CompetencyItemVm(CompetencyItem item, Action<CompetencyItemVm> onDelete,
Action<CompetencyItemVm>? onMoveUp = null, Action<CompetencyItemVm>? onMoveDown = null)
{ {
Model = item;
ItemId = item.Id; ItemId = item.Id;
Code = item.Code; Code = item.Code;
Description = item.Description; Description = item.Description;
@@ -893,6 +1007,19 @@ public class CompetencyItemVm
? item.Description ? item.Description
: $"[{item.Code}] {item.Description}"; : $"[{item.Code}] {item.Description}";
DeleteCommand = new RelayCommand(() => onDelete(this)); 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();
} }
} }
@@ -0,0 +1,68 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.CompetencyOverviewTabView"
x:DataType="vm:CompetencyOverviewTabViewModel">
<Grid RowDefinitions="Auto,*" Margin="20">
<StackPanel Grid.Row="0" Spacing="10" Margin="0,0,0,12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="Kompetenzübersicht" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="1" Content="Aktualisieren" Command="{Binding RefreshCommand}"/>
</Grid>
<Grid ColumnDefinitions="*,16,210" IsVisible="{Binding HasRows}">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Schülerprofil" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}"
PlaceholderText="Keine Schüler vorhanden" IsEnabled="{Binding HasStudents}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:CompetencyStudentOption">
<TextBlock Text="{Binding DisplayName}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="{Binding ThresholdLabel}" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding ReviewThreshold}" Minimum="0" Maximum="100"
Increment="5" FormatString="0"/>
</StackPanel>
</Grid>
<TextBlock Text="Prozentwerte beruhen auf bewerteten Klausuraufgaben. Eine Aufgabe mit mehreren Kompetenzcodes zählt für jede dieser Kompetenzen."
TextWrapping="Wrap" FontSize="11" Opacity="0.55" IsVisible="{Binding HasRows}"/>
</StackPanel>
<Grid Grid.Row="1">
<TextBlock Text="{Binding EmptyMessage}" Classes="emptyhint"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" MaxWidth="520"
IsVisible="{Binding !HasRows}"/>
<DataGrid ItemsSource="{Binding Rows}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" CanUserReorderColumns="False"
CanUserResizeColumns="True" IsVisible="{Binding HasRows}">
<DataGrid.Columns>
<DataGridTextColumn Header="Bereich" Binding="{Binding Domain}" Width="170"/>
<DataGridTextColumn Header="Code" Binding="{Binding Code}" Width="75"/>
<DataGridTextColumn Header="Kompetenz" Binding="{Binding Description}" Width="2*"/>
<DataGridTextColumn Header="Unterricht" Binding="{Binding InstructionCoverage}" Width="145"/>
<DataGridTextColumn Header="Klausuren" Binding="{Binding ExamCoverage}" Width="135"/>
<DataGridTextColumn Header="Gruppenmittel" Binding="{Binding GroupResult}" Width="210"/>
<DataGridTextColumn Header="Ausgewählter Schüler" Binding="{Binding StudentResult}" Width="160"/>
<DataGridTemplateColumn Header="Hinweis" Width="155">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:CompetencyOverviewRow">
<TextBlock Text="{Binding Recommendation}" Foreground="{Binding RecommendationColor}"
FontWeight="SemiBold" VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
public partial class CompetencyOverviewTabView : UserControl
{
public CompetencyOverviewTabView() => InitializeComponent();
}
@@ -191,6 +191,11 @@
<views:PlanningTabView DataContext="{Binding PlanningTab}"/> <views:PlanningTabView DataContext="{Binding PlanningTab}"/>
</ContentPage> </ContentPage>
<!-- Tab: Kompetenzen -->
<ContentPage Header="Kompetenzen">
<views:CompetencyOverviewTabView DataContext="{Binding CompetencyOverviewTab}"/>
</ContentPage>
<!-- Tab: Dokumentation --> <!-- Tab: Dokumentation -->
<ContentPage Header="Dokumentation"> <ContentPage Header="Dokumentation">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
@@ -132,11 +132,16 @@
Padding="12,9" Padding="12,9"
Command="{Binding NavigateToSectionCommand}" Command="{Binding NavigateToSectionCommand}"
CommandParameter="5"/> CommandParameter="5"/>
<Button Content="📋 Dokumentation" <Button Content="🎯 Kompetenzübersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9" Padding="12,9"
Command="{Binding NavigateToSectionCommand}" Command="{Binding NavigateToSectionCommand}"
CommandParameter="6"/> CommandParameter="6"/>
<Button Content="📋 Dokumentation"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="7"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -0,0 +1,29 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
x:Class="LehrerApp.Desktop.Views.Settings.CompetencyCatalogCopyTargetDialog"
x:DataType="vm:CompetencyCatalogCopyTargetViewModel"
Title="Kompetenzkatalog kopieren"
Width="460" Height="280" CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24,20">
<StackPanel Grid.Row="0" Spacing="8">
<TextBlock Text="Katalog kopieren" Classes="dialogtitle"/>
<TextBlock Text="{Binding SourceLabel}" FontWeight="SemiBold"/>
<TextBlock Text="Wähle die Zielklassenstufe. Anschließend kannst du vorhandene Einträge und Konflikte in einer Vorschau prüfen."
TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="5" Margin="0,16,0,0">
<TextBlock Text="Zielklassenstufe" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding TargetGradeLevel}" Minimum="1" Maximum="13" FormatString="0"/>
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="12"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="*,10,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Weiter zur Vorschau" HorizontalAlignment="Stretch" Click="OnContinue"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Settings;
namespace LehrerApp.Desktop.Views.Settings;
public partial class CompetencyCatalogCopyTargetDialog : Window
{
public CompetencyCatalogCopyTargetDialog() => InitializeComponent();
private void OnContinue(object? sender, RoutedEventArgs e)
{
if (DataContext is CompetencyCatalogCopyTargetViewModel vm && vm.Validate())
Close((int?)vm.TargetGradeLevel);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -3,12 +3,12 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
x:Class="LehrerApp.Desktop.Views.Settings.CompetencyCatalogImportDialog" x:Class="LehrerApp.Desktop.Views.Settings.CompetencyCatalogImportDialog"
x:DataType="vm:CompetencyCatalogImportViewModel" x:DataType="vm:CompetencyCatalogImportViewModel"
Title="Kompetenzkatalog importieren" Title="{Binding Heading}"
Width="760" Height="720" MinWidth="620" MinHeight="560" Width="760" Height="720" MinWidth="620" MinHeight="560"
CanResize="True" WindowStartupLocation="CenterOwner"> CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24,20"> <Grid RowDefinitions="Auto,*,Auto" Margin="24,20">
<StackPanel Grid.Row="0" Spacing="10"> <StackPanel Grid.Row="0" Spacing="10">
<TextBlock Text="Import prüfen" Classes="dialogtitle"/> <TextBlock Text="{Binding Heading}" Classes="dialogtitle"/>
<TextBlock Text="{Binding Target}" FontWeight="SemiBold"/> <TextBlock Text="{Binding Target}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.7"/> <TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.7"/>
@@ -111,7 +111,7 @@
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<!-- Fach + Klassenstufe Auswahl + Import/Export --> <!-- Fach + Klassenstufe Auswahl + Import/Export -->
<Grid ColumnDefinitions="*,12,120,12,Auto,8,Auto"> <Grid ColumnDefinitions="*,12,100,12,Auto,8,Auto,8,Auto">
<ComboBox Grid.Column="0" <ComboBox Grid.Column="0"
ItemsSource="{Binding Subjects}" ItemsSource="{Binding Subjects}"
SelectedItem="{Binding CatalogSubject}" SelectedItem="{Binding CatalogSubject}"
@@ -130,6 +130,9 @@
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/> IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Grid.Column="6" Content="JSON exportieren" Click="OnExportClick" <Button Grid.Column="6" Content="JSON exportieren" Click="OnExportClick"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/> IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Grid.Column="8" Content="Kopieren…" Click="OnCopyCatalogClick"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"
ToolTip.Tip="Katalog in eine andere Klassenstufe kopieren"/>
</Grid> </Grid>
<!-- Katalog-Validierungsmeldung --> <!-- Katalog-Validierungsmeldung -->
@@ -150,10 +153,14 @@
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<!-- Bereichs-Header --> <!-- Bereichs-Header -->
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto,4,Auto,8,Auto">
<TextBlock Grid.Column="0" Text="{Binding DisplayName}" <TextBlock Grid.Column="0" Text="{Binding DisplayName}"
FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/> FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Bereich löschen" FontSize="12" Padding="10,4" <Button Grid.Column="1" Content="" FontSize="13" Padding="8,3"
Command="{Binding MoveUpCommand}" ToolTip.Tip="Bereich nach oben"/>
<Button Grid.Column="3" Content="↓" FontSize="13" Padding="8,3"
Command="{Binding MoveDownCommand}" ToolTip.Tip="Bereich nach unten"/>
<Button Grid.Column="5" Content="Bereich löschen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteDomainCommand}" Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteDomainCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
</Grid> </Grid>
@@ -162,11 +169,15 @@
<ItemsControl ItemsSource="{Binding Items}"> <ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:CompetencyItemVm"> <DataTemplate DataType="vm:CompetencyItemVm">
<Grid ColumnDefinitions="*,Auto" Margin="0,3"> <Grid ColumnDefinitions="*,Auto,4,Auto,6,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Display}" <TextBlock Grid.Column="0" Text="{Binding Display}"
TextWrapping="Wrap" VerticalAlignment="Center" TextWrapping="Wrap" VerticalAlignment="Center"
FontSize="13"/> FontSize="13"/>
<Button Grid.Column="1" Content="×" Padding="7,2" FontSize="13" <Button Grid.Column="1" Content="" Padding="7,2" FontSize="12"
Command="{Binding MoveUpCommand}" ToolTip.Tip="Kompetenz nach oben"/>
<Button Grid.Column="3" Content="↓" Padding="7,2" FontSize="12"
Command="{Binding MoveDownCommand}" ToolTip.Tip="Kompetenz nach unten"/>
<Button Grid.Column="5" Content="×" Padding="7,2" FontSize="13"
Command="{Binding DeleteCommand}"/> Command="{Binding DeleteCommand}"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
@@ -93,6 +93,30 @@ public partial class SettingsView : UserControl
await File.WriteAllTextAsync(file.Path.LocalPath, json); await File.WriteAllTextAsync(file.Path.LocalPath, json);
} }
private async void OnCopyCatalogClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not SettingsViewModel { CatalogSubject: not null } vm) return;
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var targetDialog = new CompetencyCatalogCopyTargetDialog
{
DataContext = new CompetencyCatalogCopyTargetViewModel(
vm.CatalogSubject.Name, vm.CatalogGradeLevel),
};
var targetGradeLevel = await targetDialog.ShowDialog<int?>(owner);
if (targetGradeLevel is null) return;
var preview = vm.PrepareCatalogCopy(targetGradeLevel.Value);
if (preview is null) return;
var previewDialog = new CompetencyCatalogImportDialog
{
DataContext = new CompetencyCatalogImportViewModel(vm, preview, "Kopie prüfen"),
};
if (await previewDialog.ShowDialog<bool>(owner))
vm.SetCatalogCopyStatus(targetGradeLevel.Value);
}
private async void OnEditGradingKeyTemplateClick(object? sender, RoutedEventArgs e) private async void OnEditGradingKeyTemplateClick(object? sender, RoutedEventArgs e)
{ {
if (sender is not Button { Tag: GradingKeyTemplateEditItem item }) return; if (sender is not Button { Tag: GradingKeyTemplateEditItem item }) return;
@@ -0,0 +1,132 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class CompetencyAnalysisServiceTests
{
private readonly CompetencyAnalysisService _service = new();
[Fact]
public void Analyze_UnterscheidetPlanungBehandlungUndPruefung()
{
var groupId = Guid.NewGuid();
var catalog = Catalog();
var units = new List<Unit>
{
new() { GroupId = groupId, Status = UnitStatus.Planned, Competencies = ["K1"] },
new() { GroupId = groupId, Status = UnitStatus.Active, Competencies = ["K1"] },
new() { GroupId = groupId, Status = UnitStatus.Completed, Competencies = ["K1"] },
};
var exams = new List<Exam>
{
new() { GroupId = groupId, Status = ExamStatus.Planned,
Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }] },
new() { GroupId = groupId, Status = ExamStatus.Graded,
Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }] },
};
var item = Assert.Single(_service.Analyze(catalog, units, exams, []));
Assert.Equal(1, item.PlannedUnitCount);
Assert.Equal(2, item.TaughtUnitCount);
Assert.Equal(1, item.PlannedExamCount);
Assert.Equal(1, item.AssessedExamCount);
}
[Fact]
public void Analyze_BerechnetGewichteteGruppenUndSchuelerprofileAusAufgabenpunkten()
{
var firstStudent = Guid.NewGuid();
var secondStudent = Guid.NewGuid();
var absentStudent = Guid.NewGuid();
var exam = new Exam
{
Status = ExamStatus.Graded,
Tasks =
[
new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] },
new ExamTask { MaxPoints = 20, CompetencyCodes = ["K1"] },
],
};
var results = new List<ExamResult>
{
new() { ExamId = exam.Id, StudentId = firstStudent, Points = [5, 10] },
new() { ExamId = exam.Id, StudentId = secondStudent, Points = [10, 20] },
new() { ExamId = exam.Id, StudentId = absentStudent, Points = [0, 0], Absent = true },
};
var item = Assert.Single(_service.Analyze(Catalog(), [], [exam], results));
Assert.NotNull(item.GroupScore);
Assert.Equal(75, item.GroupScore.Percent, 6);
Assert.Equal(2, item.GroupScore.ParticipantCount);
Assert.Equal(4, item.GroupScore.EvidenceCount);
Assert.Equal(50, item.StudentScores[firstStudent].Percent, 6);
Assert.Equal(100, item.StudentScores[secondStudent].Percent, 6);
Assert.False(item.StudentScores.ContainsKey(absentStudent));
}
[Fact]
public void Analyze_AufgabeMitMehrerenCodes_WertetJedeZugeordneteKompetenzAus()
{
var studentId = Guid.NewGuid();
var exam = new Exam
{
Status = ExamStatus.Returned,
Tasks = [new ExamTask { MaxPoints = 8, CompetencyCodes = ["K1", "K2"] }],
};
var result = new ExamResult { ExamId = exam.Id, StudentId = studentId, Points = [6] };
var analysis = _service.Analyze(Catalog(includeSecond: true), [], [exam], [result]);
Assert.All(analysis, item => Assert.Equal(75, item.StudentScores[studentId].Percent, 6));
}
[Fact]
public void Analyze_DurchgefuehrteAberNichtKorrigierteKlausur_LiefertNochKeinProfil()
{
var studentId = Guid.NewGuid();
var exam = new Exam
{
Status = ExamStatus.Conducted,
Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }],
};
var result = new ExamResult { ExamId = exam.Id, StudentId = studentId, Points = [5] };
var item = Assert.Single(_service.Analyze(Catalog(), [], [exam], [result]));
Assert.Equal(1, item.AssessedExamCount);
Assert.Null(item.GroupScore);
Assert.Empty(item.StudentScores);
}
[Fact]
public void Analyze_VerwendeterUnbekannterCode_BleibtInUebersichtSichtbar()
{
var unit = new Unit { Status = UnitStatus.Active, Competencies = ["ALT1"] };
var analysis = _service.Analyze(Catalog(), [unit], [], []);
var unknown = Assert.Single(analysis, x => x.Code == "ALT1");
Assert.Equal("Nicht im Katalog", unknown.DomainName);
Assert.Equal(1, unknown.TaughtUnitCount);
}
private static List<CompetencyDomain> Catalog(bool includeSecond = false) =>
[
new CompetencyDomain
{
Name = "Bereich",
Code = "B",
Items = includeSecond
?
[
new CompetencyItem { Code = "K1", Description = "Erste Kompetenz", SortOrder = 0 },
new CompetencyItem { Code = "K2", Description = "Zweite Kompetenz", SortOrder = 1 },
]
: [new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" }],
},
];
}
+15 -6
View File
@@ -990,11 +990,15 @@ Katalogverwaltung und JSON-Import existieren in
Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-Prompt.md). Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-Prompt.md).
### 8.1 Katalogverwaltung ### 8.1 Katalogverwaltung
- [ ] **8.1.1** Kompetenzen innerhalb eines Bereichs umsortieren (`SortOrder` bearbeitbar machen). - [x] **8.1.1** Kompetenzen innerhalb eines Bereichs umsortieren (`SortOrder` bearbeitbar machen).
Bereiche und einzelne Kompetenzen lassen sich über Hoch-/Runter-Schaltflächen verschieben;
die Reihenfolge wird unmittelbar und lückenlos in `SortOrder` gespeichert.
- [x] **8.1.2** Katalog exportieren (JSON) — Gegenstück zum vorhandenen Import. - [x] **8.1.2** Katalog exportieren (JSON) — Gegenstück zum vorhandenen Import.
Bereits über „JSON exportieren“ in den Einstellungen umgesetzt; Fach, Klassenstufe, Bereits über „JSON exportieren“ in den Einstellungen umgesetzt; Fach, Klassenstufe,
Bereiche und Kompetenzen werden vollständig ausgegeben. Bereiche und Kompetenzen werden vollständig ausgegeben.
- [ ] **8.1.3** Katalog von einer Jahrgangsstufe in eine andere kopieren. - [x] **8.1.3** Katalog von einer Jahrgangsstufe in eine andere kopieren.
Zielklassenstufe wird in einem Dialog gewählt; anschließend nutzt die Kopie dieselbe sichere
Vorschau, Konfliktauswahl und Merge-/Ersetzen-Logik wie der JSON-Import.
- [x] **8.1.4** Import-Konflikte behandeln: Merge statt Ersetzen anbieten. - [x] **8.1.4** Import-Konflikte behandeln: Merge statt Ersetzen anbieten.
Umgesetzt mit vollständiger Validierung vor dem Schreiben, Importvorschau, sicherem Merge Umgesetzt mit vollständiger Validierung vor dem Schreiben, Importvorschau, sicherem Merge
als Vorauswahl, Einzelentscheidung je Konflikt und bestätigungspflichtigem atomarem Ersetzen. als Vorauswahl, Einzelentscheidung je Konflikt und bestätigungspflichtigem atomarem Ersetzen.
@@ -1004,12 +1008,17 @@ Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-
Bereits im Unterrichtseinheiten-Dialog über die Kompetenz-Auswahl umgesetzt und in Bereits im Unterrichtseinheiten-Dialog über die Kompetenz-Auswahl umgesetzt und in
`Unit.Competencies` gespeichert. `Unit.Competencies` gespeichert.
- [x] **8.2.2** Kompetenzen einzelnen Klausuraufgaben zuordnen — umgesetzt mit 1.2.4. - [x] **8.2.2** Kompetenzen einzelnen Klausuraufgaben zuordnen — umgesetzt mit 1.2.4.
- [ ] **8.2.3** Abdeckungsübersicht: welche Kompetenzen wurden im Schuljahr behandelt/geprüft? - [x] **8.2.3** Abdeckungsübersicht: welche Kompetenzen wurden im Schuljahr behandelt/geprüft?
Eigener Tab je Lerngruppe unterscheidet geplante und behandelte Unterrichtseinheiten sowie
geplante und tatsächlich durchgeführte Klausuren pro Kompetenz.
### 8.3 Kompetenzorientierte Auswertung ### 8.3 Kompetenzorientierte Auswertung
- [ ] **8.3.1** Kompetenzprofil je Schüler aus Klausuraufgaben-Ergebnissen berechnen. - [x] **8.3.1** Kompetenzprofil je Schüler aus Klausuraufgaben-Ergebnissen berechnen.
- [ ] **8.3.2** Gruppenübersicht: durchschnittlicher Erfüllungsgrad je Kompetenz, Aufgabenpunkte korrigierter/zurückgegebener Klausuren werden nach zugeordneten Kompetenzcodes
Identifikation von Wiederholungsbedarf. aggregiert; abwesende Schüler und Aufgaben ohne positive Maximalpunktzahl werden nicht gewertet.
- [x] **8.3.2** Gruppenübersicht: durchschnittlicher Erfüllungsgrad je Kompetenz,
Identifikation von Wiederholungsbedarf. Gruppenmittel, Zahl der beteiligten Schüler und
Aufgabenwerte werden angezeigt; die Schwelle für Wiederholungsbedarf ist frei einstellbar.
- [ ] **8.3.3** Kompetenzbericht je Schüler als Ausdruck/Export. - [ ] **8.3.3** Kompetenzbericht je Schüler als Ausdruck/Export.
--- ---