Zeugnisnoten-Assistent mit Mitarbeitsverlauf und Prüfungsnoten Klasse 10
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Neuer Assistent (Einstieg im Zeugnisnoten-Dialog) geht die Lerngruppe Schüler für Schüler durch: berechnete Note mit ungerundetem Wert und Teilbereichen, Mitarbeitsgraph abschnittsweise oder als Verlauf, Arbeiten und Einzelnoten. Das 1. Halbjahr erscheint im 2. Halbjahr nur als Kontext. Die Endjahresnote wird jetzt in Dialog und Assistent aus den Halbjahresnoten verrechnet (gemeinsamer ReportGradeCalculator). In Klasse 10 kommen Projektprüfung, schriftliche/mündliche Abschlussprüfung und zusätzliche mündliche Prüfung hinzu, gewichtet nach Oberschulverordnung und PrüfV Sek. I; gerundet wird dabei bewusst nicht automatisch. Die Zeitleisten-Erzeugung des Mitarbeits-Assistenten liegt dafür im gemeinsamen ParticipationTimelineBuilder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
20a3ce8896
commit
67c5a906d1
18 files changed
+2017
-229
No files matched your search
@@ -258,6 +258,19 @@ public class ReportGrade
|
||||
public string? OverrideValue { get; set; }
|
||||
public string? OverrideReason { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
/// <summary>Nur am Datensatz "Gesamtes Schuljahr": Note der Projektprüfung, wenn sie in
|
||||
/// diesem Fach abgelegt wurde (Klasse 10). Zählt in der Endjahresnote wie ein weiteres
|
||||
/// Halbjahr.</summary>
|
||||
public string? ProjectExamGrade { get; set; }
|
||||
/// <summary>Wie <see cref="ProjectExamGrade"/>, für die mündliche Abschlussprüfung im
|
||||
/// gewählten Fach.</summary>
|
||||
public string? OralExamGrade { get; set; }
|
||||
/// <summary>Note der schriftlichen Abschlussprüfung (Deutsch, Mathematik, erste
|
||||
/// Fremdsprache), nur am Jahres-Datensatz.</summary>
|
||||
public string? WrittenExamGrade { get; set; }
|
||||
/// <summary>Note einer zusätzlichen mündlichen Prüfung ("Nachprüfung", § 8 Abs. 3 und 4
|
||||
/// PrüfV Sek. I), nur am Jahres-Datensatz. Zur Verrechnung siehe docs/Datenmodell.md.</summary>
|
||||
public string? AdditionalOralExamGrade { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ public class GradingService
|
||||
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
|
||||
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
|
||||
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
|
||||
private static double? ParseGradeValue(string grade)
|
||||
public static double? ParseGradeValue(string grade)
|
||||
{
|
||||
var trimmed = grade.Trim();
|
||||
if (trimmed.Length == 0) return null;
|
||||
@@ -132,6 +132,18 @@ public class GradingService
|
||||
List<(string Grade, double Weight)> participationGrades,
|
||||
List<(string Grade, double Weight)> otherGrades,
|
||||
GradingScheme scheme, GradingSystem system, RoundingRule rounding)
|
||||
{
|
||||
var weighted = CalculateReportGradeValue(examGrades, participationGrades, otherGrades, scheme);
|
||||
return weighted is null ? null : RoundToGrade(weighted.Value, system, rounding);
|
||||
}
|
||||
|
||||
/// Wie <see cref="CalculateReportGrade"/>, liefert aber den ungerundeten gewichteten Wert —
|
||||
/// für Ansichten, die zeigen sollen, wie knapp eine Note zwischen zwei Stufen liegt.
|
||||
public double? CalculateReportGradeValue(
|
||||
List<(string Grade, double Weight)> examGrades,
|
||||
List<(string Grade, double Weight)> participationGrades,
|
||||
List<(string Grade, double Weight)> otherGrades,
|
||||
GradingScheme scheme)
|
||||
{
|
||||
var buckets = new List<(double Avg, double Percent)>();
|
||||
if (examGrades.Count > 0) buckets.Add((WeightedAverage(examGrades), scheme.ExamsPercent));
|
||||
@@ -141,8 +153,7 @@ public class GradingService
|
||||
var totalPercent = buckets.Sum(b => b.Percent);
|
||||
if (buckets.Count == 0 || totalPercent <= 0) return null;
|
||||
|
||||
var weighted = buckets.Sum(b => b.Avg * b.Percent) / totalPercent;
|
||||
return RoundToGrade(weighted, system, rounding);
|
||||
return buckets.Sum(b => b.Avg * b.Percent) / totalPercent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,14 @@ public class FakeAspects : IParticipationAspectRepository
|
||||
public void Delete(Guid id) => _all.RemoveAll(a => a.Id == id);
|
||||
}
|
||||
|
||||
public class FakeSections(List<ParticipationSection>? initial = null) : IParticipationSectionRepository
|
||||
{
|
||||
private readonly List<ParticipationSection> _all = initial ?? [];
|
||||
public List<ParticipationSection> GetByGroup(Guid groupId) => _all.Where(s => s.GroupId == groupId).ToList();
|
||||
public void Save(ParticipationSection section) { _all.RemoveAll(s => s.Id == section.Id); _all.Add(section); }
|
||||
public void Delete(Guid id) => _all.RemoveAll(s => s.Id == id);
|
||||
}
|
||||
|
||||
public class FakeGrades : IGradeRepository
|
||||
{
|
||||
private readonly List<Grade> _all = [];
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für den Zeugnisnoten-Assistenten: eigenständige Halbjahre (H1 nur als Kontext in H2),
|
||||
/// Endjahresnote aus den beiden Halbjahresnoten, Speichern/Festschreiben über dieselben
|
||||
/// ReportGrade-Datensätze wie der Zeugnisnoten-Dialog sowie die beiden Graph-Modi.
|
||||
public sealed class ReportGradeAssistantViewModelTests
|
||||
{
|
||||
private static readonly Guid GroupId = Guid.NewGuid();
|
||||
private static readonly Student Anna = new() { Id = Guid.NewGuid(), FirstName = "Anna", LastName = "Beispiel" };
|
||||
private static readonly Student Ben = new() { Id = Guid.NewGuid(), FirstName = "Ben", LastName = "Muster" };
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
public List<Exam> Exams { get; } = [];
|
||||
public FakeResults Results { get; } = new();
|
||||
public FakeGrades Grades { get; } = new();
|
||||
public FakeReportGrades ReportGrades { get; } = new();
|
||||
public List<ParticipationSession> Sessions { get; } = [];
|
||||
public FakeEntries Entries { get; } = new();
|
||||
public List<ParticipationSection> Sections { get; } = [];
|
||||
|
||||
public ReportGradeAssistantViewModel Build(DateOnly today, int gradeLevel = 8)
|
||||
{
|
||||
var schemes = new FakeSchemes();
|
||||
schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 });
|
||||
return new ReportGradeAssistantViewModel(
|
||||
Grades, new FakeExams(Exams), Results, new FakeStudents([Anna, Ben]), new FakeMemberships([]),
|
||||
schemes, ReportGrades, new FakeSessions(Sessions), Entries, new FakeAspects(),
|
||||
new FakeSections(Sections), new AttendanceBalanceService(), new GradingService(),
|
||||
GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", gradeLevel, today);
|
||||
}
|
||||
|
||||
public Exam AddExam(string title, DateOnly date, string annaGrade)
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = title, Date = date };
|
||||
Exams.Add(exam);
|
||||
Results.Add(new ExamResult { ExamId = exam.Id, StudentId = Anna.Id, Grade = annaGrade });
|
||||
return exam;
|
||||
}
|
||||
|
||||
public void AddParticipation(DateOnly date, string value, string? note = null) =>
|
||||
Grades.Add(new Grade { StudentId = Anna.Id, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = date, Value = value, Note = note });
|
||||
}
|
||||
|
||||
private static readonly DateOnly InH1 = new(2025, 11, 10);
|
||||
private static readonly DateOnly InH2 = new(2026, 4, 20);
|
||||
|
||||
[Fact]
|
||||
public void Vorbelegung_WaehltDasLaufendeHalbjahr()
|
||||
{
|
||||
Assert.Equal(ParticipationPeriod.H1, new Fixture().Build(InH1).SelectedPeriod.Period);
|
||||
Assert.Equal(ParticipationPeriod.H2, new Fixture().Build(InH2).SelectedPeriod.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Halbjahr_BerechnetNoteUndZeigtTeilbereiche()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
|
||||
f.AddParticipation(new DateOnly(2025, 10, 15), "3");
|
||||
|
||||
var vm = f.Build(InH1);
|
||||
|
||||
// (2*50 + 3*40) / 90 = 2,44 -> 2
|
||||
Assert.Equal("2", vm.CalculatedDisplay);
|
||||
Assert.Contains("2,44", vm.CalculationDetail);
|
||||
Assert.Equal(["2,00", "3,00", "–"], vm.Tiles.Select(t => t.Value));
|
||||
Assert.Equal("2", vm.Students[0].GradeDisplay);
|
||||
Assert.Equal(AssistantGradeStatus.Open, vm.Students[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZweitesHalbjahr_ErstesZaehltNichtWirdAberAlsKontextGezeigt()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "5");
|
||||
f.AddExam("K2", new DateOnly(2026, 3, 1), "2");
|
||||
f.AddParticipation(new DateOnly(2025, 12, 1), "5");
|
||||
|
||||
var vm = f.Build(InH2);
|
||||
|
||||
Assert.Equal("2", vm.CalculatedDisplay);
|
||||
Assert.Equal(2, vm.Works.Count);
|
||||
Assert.True(vm.Works[0].IsContext);
|
||||
Assert.False(vm.Works[1].IsContext);
|
||||
Assert.True(Assert.Single(vm.ParticipationGrades).IsContext);
|
||||
|
||||
vm.ShowPreviousHalf = false;
|
||||
|
||||
Assert.Equal("K2", Assert.Single(vm.Works).Title);
|
||||
Assert.Empty(vm.ParticipationGrades);
|
||||
Assert.Equal("2", vm.CalculatedDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schuljahr_VerrechnetHalbjahresnotenUndBevorzugtGespeicherteEntscheidung()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
|
||||
f.AddExam("K2", new DateOnly(2026, 3, 1), "4");
|
||||
var vm = f.Build(InH2);
|
||||
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
|
||||
// (2 + 4) / 2 = 3
|
||||
Assert.Equal("3", vm.CalculatedDisplay);
|
||||
Assert.Equal(["2", "4"], vm.Tiles.Select(t => t.Value));
|
||||
|
||||
// Pädagogisch übersteuerte Halbjahresnote geht in die Verrechnung ein, nicht die berechnete.
|
||||
f.ReportGrades.Save(new ReportGrade { StudentId = Anna.Id, GroupId = GroupId, Period = "1. Halbjahr",
|
||||
CalculatedValue = "2", OverrideValue = "1", OverrideReason = "starke Entwicklung" });
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H2);
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
|
||||
// (1 + 4) / 2 = 2,5 -> kaufmännisch 3, pädagogisch 2
|
||||
Assert.Equal("3", vm.CalculatedDisplay);
|
||||
Assert.Contains("2,50", vm.CalculationDetail);
|
||||
vm.RoundingRule = RoundingRule.Pedagogical;
|
||||
Assert.Equal("2", vm.CalculatedDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Klasse10_PruefungsnotenZaehlenWieWeitereHalbjahre()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
|
||||
f.AddExam("K2", new DateOnly(2026, 3, 1), "2");
|
||||
var vm = f.Build(InH2, gradeLevel: 10);
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
|
||||
Assert.True(vm.CanEnterExamGrades);
|
||||
Assert.Equal("2", vm.CalculatedDisplay);
|
||||
|
||||
// (2 + 2 + 5) / 3 = 3
|
||||
vm.ProjectExamGrade = "5";
|
||||
Assert.Equal("3", vm.CalculatedDisplay);
|
||||
Assert.Equal(3, vm.Tiles.Count);
|
||||
|
||||
// (2 + 2 + 5 + 5) / 4 = 3,5 -> 4
|
||||
vm.OralExamGrade = "5";
|
||||
Assert.Equal("4", vm.CalculatedDisplay);
|
||||
Assert.Contains("4 Teilnoten", vm.CalculationDetail);
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "Gesamtes Schuljahr");
|
||||
Assert.Equal("5", record!.ProjectExamGrade);
|
||||
Assert.Equal("5", record.OralExamGrade);
|
||||
Assert.Equal("4", record.CalculatedValue);
|
||||
Assert.Equal("4", vm.Students[0].GradeDisplay);
|
||||
}
|
||||
|
||||
private static ReportGradeAssistantViewModel Klasse10Schuljahr(Fixture f, string h1, string h2)
|
||||
{
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), h1);
|
||||
f.AddExam("K2", new DateOnly(2026, 3, 1), h2);
|
||||
var vm = f.Build(InH2, gradeLevel: 10);
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
return vm;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Klasse10_SchriftlichePruefungGehtMitEinemDrittelEin()
|
||||
{
|
||||
var vm = Klasse10Schuljahr(new Fixture(), "2", "2");
|
||||
|
||||
// § 14 Abs. 3 ObSchVO: (2 * 2 + 5) / 3 = 3,0
|
||||
vm.WrittenExamGrade = "5";
|
||||
|
||||
Assert.Equal("3", vm.CalculatedDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Klasse10_ZusaetzlicheMuendlicheImSchriftlichenFach_EinsZuEinsOhneRundung()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var vm = Klasse10Schuljahr(f, "4", "4");
|
||||
vm.WrittenExamGrade = "6";
|
||||
|
||||
// (4 + 4 + 6) / 3 = 4,67 -> 5
|
||||
Assert.Equal("5", vm.CalculatedDisplay);
|
||||
|
||||
// § 13 Abs. 2 PrüfV: (6 + 3) / 2 = 4,5 — bleibt ungerundet; (4 + 4 + 4,5) / 3 = 4,17 -> 4
|
||||
vm.AdditionalOralExamGrade = "3";
|
||||
|
||||
Assert.Equal("4", vm.CalculatedDisplay);
|
||||
Assert.Contains("4,17", vm.CalculationDetail);
|
||||
var tile = vm.Tiles.Single(t => t.Label == "Prüfungsleistung");
|
||||
Assert.Equal("4,5", tile.Value);
|
||||
Assert.Contains("zwischen 4 und 5", tile.Hint);
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "Gesamtes Schuljahr");
|
||||
Assert.Equal("6", record!.WrittenExamGrade);
|
||||
Assert.Equal("3", record.AdditionalOralExamGrade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenauZwischenZweiNoten_WeistAufPaedagogischeEntscheidungHin()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
|
||||
f.AddExam("K2", new DateOnly(2026, 3, 1), "3");
|
||||
var vm = f.Build(InH2);
|
||||
Assert.Empty(vm.BorderlineHint);
|
||||
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
|
||||
// (2 + 3) / 2 = 2,5
|
||||
Assert.Contains("zwischen 2 und 3", vm.BorderlineHint);
|
||||
|
||||
vm.ToggleLockCommand.Execute(null);
|
||||
Assert.Empty(vm.BorderlineHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Klasse10_NachpruefungOhneAbschlusspruefung_ZweiZuEins()
|
||||
{
|
||||
var vm = Klasse10Schuljahr(new Fixture(), "5", "5");
|
||||
|
||||
// § 13 Abs. 3 PrüfV: (2 * 5 + 3) / 3 = 4,33 -> 4
|
||||
vm.AdditionalOralExamGrade = "3";
|
||||
|
||||
Assert.Equal("4", vm.CalculatedDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Klasse10_NurEinHalbjahr_UnterrichtlicheLeistungZaehltTrotzdemDoppelt()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K2", new DateOnly(2026, 3, 1), "2");
|
||||
var vm = f.Build(InH2, gradeLevel: 10);
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
|
||||
// (2 * 2 + 5) / 3 = 3 — nicht (2 + 5) / 2 = 3,5 -> 4
|
||||
vm.OralExamGrade = "5";
|
||||
|
||||
Assert.Equal("3", vm.CalculatedDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Klasse10_WiderspruechlichePruefungen_WerdenAbgelehnt()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var vm = Klasse10Schuljahr(f, "3", "3");
|
||||
|
||||
vm.WrittenExamGrade = "3";
|
||||
vm.OralExamGrade = "2";
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.NotEmpty(vm.OralExamGradeError);
|
||||
|
||||
vm.WrittenExamGrade = "";
|
||||
vm.AdditionalOralExamGrade = "4";
|
||||
vm.SaveCommand.Execute(null);
|
||||
Assert.NotEmpty(vm.AdditionalOralExamGradeError);
|
||||
Assert.Equal(0, f.ReportGrades.SavedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Klasse10_UngueltigePruefungsnote_WirdNichtGespeichert()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
|
||||
var vm = f.Build(InH2, gradeLevel: 10);
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
|
||||
vm.ProjectExamGrade = "gut";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotEmpty(vm.ProjectExamGradeError);
|
||||
Assert.Equal(0, f.ReportGrades.SavedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PruefungsnotenNurInKlasse10UndNurFuerDasSchuljahr()
|
||||
{
|
||||
var klasse8 = new Fixture().Build(InH2);
|
||||
klasse8.SelectedPeriod = klasse8.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
|
||||
Assert.False(klasse8.CanEnterExamGrades);
|
||||
|
||||
Assert.False(new Fixture().Build(InH2, gradeLevel: 10).CanEnterExamGrades);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Speichern_UebersteuernOhneBegruendung_WirdAbgelehnt()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
|
||||
var vm = f.Build(InH1);
|
||||
|
||||
vm.OverrideValue = "2";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotEmpty(vm.OverrideReasonError);
|
||||
Assert.Equal(0, f.ReportGrades.SavedCount);
|
||||
|
||||
vm.OverrideReason = "mündlich deutlich stärker";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "1. Halbjahr");
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal("3", record.CalculatedValue);
|
||||
Assert.Equal("2", record.OverrideValue);
|
||||
Assert.Equal("2", vm.Students[0].GradeDisplay);
|
||||
Assert.Equal(AssistantGradeStatus.Saved, vm.Students[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpeichernUndWeiter_GehtZumNaechstenSchueler()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
|
||||
var vm = f.Build(InH1);
|
||||
|
||||
vm.SaveAndNextCommand.Execute(null);
|
||||
|
||||
Assert.Equal(Ben.Id, vm.SelectedStudent!.StudentId);
|
||||
Assert.Equal("2 / 2", vm.ProgressText);
|
||||
Assert.False(vm.NextStudentCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Festschreiben_FriertBerechnetenStandEin()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
|
||||
var vm = f.Build(InH1);
|
||||
|
||||
vm.ToggleLockCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsLocked);
|
||||
Assert.Equal(AssistantGradeStatus.Locked, vm.Students[0].Status);
|
||||
|
||||
// Eine später eingetragene Arbeit ändert den festgeschriebenen Stand nicht mehr.
|
||||
f.AddExam("K2", new DateOnly(2025, 12, 1), "1");
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H2);
|
||||
vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H1);
|
||||
Assert.Equal("3", vm.CalculatedDisplay);
|
||||
|
||||
vm.ToggleLockCommand.Execute(null);
|
||||
Assert.False(vm.IsLocked);
|
||||
Assert.Equal("2", vm.CalculatedDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeitsgraph_AbschnitteBlaetternUndVerlaufAmStueck()
|
||||
{
|
||||
var f = new Fixture();
|
||||
f.Sections.Add(new ParticipationSection { GroupId = GroupId, Label = "Abschnitt 1",
|
||||
StartDate = new DateOnly(2025, 8, 20), EndDate = new DateOnly(2025, 9, 30) });
|
||||
f.Sections.Add(new ParticipationSection { GroupId = GroupId, Label = "Abschnitt 2",
|
||||
StartDate = new DateOnly(2025, 10, 1), EndDate = new DateOnly(2025, 10, 31) });
|
||||
f.AddParticipation(new DateOnly(2025, 9, 30), "2", "Abschnitt: Abschnitt 1");
|
||||
foreach (var date in new[] { new DateOnly(2025, 9, 2), new DateOnly(2025, 10, 7), new DateOnly(2025, 11, 4) })
|
||||
{
|
||||
var session = new ParticipationSession { Id = Guid.NewGuid(), GroupId = GroupId, Date = date };
|
||||
f.Sessions.Add(session);
|
||||
f.Entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Anna.Id,
|
||||
Ratings = [new AspectRating { Key = "quality", Value = 1 }] });
|
||||
}
|
||||
|
||||
var vm = f.Build(InH1);
|
||||
|
||||
Assert.True(vm.IsSectionMode);
|
||||
Assert.Equal("1 / 3", vm.SectionPosition);
|
||||
Assert.Contains("Abschnittsnote 2", vm.SectionTitle);
|
||||
Assert.Single(Assert.Single(vm.ChartGroups).Points);
|
||||
Assert.False(vm.PreviousSectionCommand.CanExecute(null));
|
||||
|
||||
vm.NextSectionCommand.Execute(null);
|
||||
Assert.Contains("keine Abschnittsnote", vm.SectionTitle);
|
||||
vm.NextSectionCommand.Execute(null);
|
||||
Assert.Contains("vorläufig", vm.SectionTitle);
|
||||
Assert.True(vm.ChartGroups[0].IsOpen);
|
||||
Assert.False(vm.NextSectionCommand.CanExecute(null));
|
||||
|
||||
vm.IsSectionMode = false;
|
||||
|
||||
Assert.Equal(3, vm.ChartGroups.Count);
|
||||
Assert.Equal(3, vm.ChartGroups.Sum(g => g.Points.Count));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,33 @@ public class ReportGradeCalculationTests
|
||||
Assert.Equal("1", annaNachSperre.FinalDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schuljahr_WirdAusHalbjahresnotenUndPruefungsnotenVerrechnet()
|
||||
{
|
||||
// H1: eine 1 und eine 2 -> 1,5 -> 2. H2: eine 4 -> 4.
|
||||
var h1a = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 9, 10), Title = "K1" };
|
||||
var h1b = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 11, 10), Title = "K2" };
|
||||
var h2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2026, 3, 10), Title = "K3" };
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = h1a.Id, StudentId = Anna.Id, Grade = "1" });
|
||||
results.Add(new ExamResult { ExamId = h1b.Id, StudentId = Anna.Id, Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = h2.Id, StudentId = Anna.Id, Grade = "4" });
|
||||
var reportGrades = new FakeReportGrades();
|
||||
|
||||
var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), reportGrades, new FakeExams([h1a, h1b, h2]), results);
|
||||
|
||||
// Aus den Halbjahresnoten: (2 + 4) / 2 = 3 — nicht aus allen Arbeiten ((1+2+4)/3 = 2,33 -> 2).
|
||||
Assert.Equal("3", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue);
|
||||
|
||||
// Klasse 10: eine im Fach abgelegte Projektprüfung zählt wie ein drittes Halbjahr.
|
||||
reportGrades.Save(new ReportGrade { StudentId = Anna.Id, GroupId = GroupId, Period = "Gesamtes Schuljahr",
|
||||
ProjectExamGrade = "6" });
|
||||
vm.Refresh();
|
||||
|
||||
// (2 + 4 + 6) / 3 = 4
|
||||
Assert.Equal("4", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportCsv_EnthaeltAlleSchuelerDerGruppe()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Zeitleisten-Punkte eines Schülers (Mitarbeit-Sitzungen, Arbeiten, sonstige Noten) für
|
||||
/// <c>ParticipationTimelineChart</c>. Gemeinsame Grundlage von Mitarbeits-Assistent und
|
||||
/// Zeugnisnoten-Assistent, damit beide denselben Graphen mit denselben Regeln zeigen.
|
||||
/// </summary>
|
||||
public sealed class ParticipationTimelineBuilder
|
||||
{
|
||||
/// Kennzeichnet in <see cref="Grade.Note"/> eine Abschnittsnote der Mitarbeit.
|
||||
public const string SectionNotePrefix = "Abschnitt: ";
|
||||
|
||||
private readonly IParticipationRepository _entries;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IReadOnlyDictionary<Guid, GroupMembership> _membershipsByStudent;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Guid _groupId;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly List<ParticipationSession> _sessions;
|
||||
private readonly Dictionary<string, double> _aspectWeights;
|
||||
// Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
|
||||
// Gewichtung, analog zu ParticipationGradeDialogViewModel.
|
||||
private readonly Dictionary<string, ParticipationAspect> _aspectsByKey;
|
||||
|
||||
public IReadOnlyList<ParticipationSession> Sessions => _sessions;
|
||||
|
||||
public ParticipationTimelineBuilder(
|
||||
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
||||
IParticipationAspectRepository aspects, IExamRepository exams, IExamResultRepository results,
|
||||
IGradeRepository grades, IReadOnlyDictionary<Guid, GroupMembership> membershipsByStudent,
|
||||
GradingService grading, Guid groupId, GradingSystem gradingSystem)
|
||||
{
|
||||
_entries = entries; _exams = exams; _results = results; _grades = grades;
|
||||
_membershipsByStudent = membershipsByStudent; _grading = grading;
|
||||
_groupId = groupId; _gradingSystem = gradingSystem;
|
||||
|
||||
var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
|
||||
.GroupBy(a => a.Key).Select(g => g.Last()).ToList();
|
||||
_aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight);
|
||||
_aspectsByKey = applicableAspects.ToDictionary(a => a.Key);
|
||||
_sessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
|
||||
}
|
||||
|
||||
public static string BandLabel(ParticipationSection section) =>
|
||||
$"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}";
|
||||
|
||||
public static string OpenBandLabel(DateOnly openStart) => $"läuft seit {openStart:dd.MM.}";
|
||||
|
||||
/// Beginn des laufenden, noch nicht abgeschlossenen Abschnitts.
|
||||
public DateOnly OpenStart(IReadOnlyCollection<ParticipationSection> sections, DateOnly today) =>
|
||||
sections.Count > 0 ? sections.Max(s => s.EndDate).AddDays(1)
|
||||
: (_sessions.Count > 0 ? _sessions.Min(s => s.Date) : today);
|
||||
|
||||
public bool IsStudentActiveOn(Guid studentId, DateOnly date) =>
|
||||
!_membershipsByStudent.TryGetValue(studentId, out var membership)
|
||||
|| GroupMembershipService.IsActiveOn(membership, date);
|
||||
|
||||
/// Alle Punkte des Schülers, chronologisch sortiert.
|
||||
public List<WizardTimelinePoint> BuildPoints(Guid studentId)
|
||||
{
|
||||
var points = new List<WizardTimelinePoint>();
|
||||
|
||||
foreach (var session in _sessions)
|
||||
{
|
||||
if (!IsStudentActiveOn(studentId, session.Date)) continue;
|
||||
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
|
||||
points.Add(BuildSessionPoint(session, entry, studentId));
|
||||
}
|
||||
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
|
||||
{
|
||||
if (!IsStudentActiveOn(studentId, exam.Date)) continue;
|
||||
var result = _results.GetByExamAndStudent(exam.Id, studentId);
|
||||
if (result is null) continue;
|
||||
points.Add(BuildExamPoint(exam, result));
|
||||
}
|
||||
foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
|
||||
.Where(g => g.Category != GradeCategory.Participation)
|
||||
.Where(g => IsStudentActiveOn(studentId, g.Date)))
|
||||
{
|
||||
points.Add(BuildOtherGradePoint(grade));
|
||||
}
|
||||
return points.OrderBy(p => p.Date).ToList();
|
||||
}
|
||||
|
||||
/// Notenvorschlag aus den bewerteten Sitzungen im Zeitraum; null ohne bewertete Sitzung.
|
||||
public string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
|
||||
{
|
||||
var points = new List<double>();
|
||||
foreach (var session in _sessions.Where(s => s.Date >= start && s.Date <= end)
|
||||
.Where(s => IsStudentActiveOn(studentId, s.Date)))
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
|
||||
if (entry is null) continue;
|
||||
var weighted = WeightedRating(entry);
|
||||
if (weighted is not null) points.Add(weighted.Value);
|
||||
}
|
||||
return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
|
||||
}
|
||||
|
||||
private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
|
||||
{
|
||||
var weightedValue = entry is not null ? WeightedRating(entry) : null;
|
||||
var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : "";
|
||||
var note = entry?.Note;
|
||||
var tooltip = $"{session.Date:dd.MM.yyyy}" +
|
||||
(ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
|
||||
(string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
|
||||
|
||||
var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
|
||||
hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
|
||||
{
|
||||
Homework = entry is null ? null : HomeworkDisplay.Effective(entry),
|
||||
AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
|
||||
AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
|
||||
AttendanceColor = AttendanceDisplay.Color(entry?.Attendance),
|
||||
AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value)
|
||||
?? new Dictionary<string, int>(),
|
||||
WeightedValue = weightedValue,
|
||||
OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem),
|
||||
};
|
||||
point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
|
||||
point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
|
||||
return point;
|
||||
}
|
||||
|
||||
private static WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
|
||||
{
|
||||
var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
|
||||
return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
|
||||
hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
|
||||
}
|
||||
|
||||
private static WizardTimelinePoint BuildOtherGradePoint(Grade grade)
|
||||
{
|
||||
var category = grade.Category switch
|
||||
{
|
||||
GradeCategory.Oral => "Mündlich",
|
||||
GradeCategory.Homework => "Hausaufgabe",
|
||||
GradeCategory.Project => "Projekt",
|
||||
_ => "Sonstige Leistung",
|
||||
};
|
||||
var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim();
|
||||
var label = $"{detail}: {grade.Value}";
|
||||
return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label,
|
||||
hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}");
|
||||
}
|
||||
|
||||
private double? WeightedRating(ParticipationEntry entry)
|
||||
{
|
||||
if (entry.Ratings.Count == 0) return null;
|
||||
var weightSum = 0.0; var valueSum = 0.0;
|
||||
foreach (var r in entry.Ratings)
|
||||
{
|
||||
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
|
||||
if (w <= 0) continue;
|
||||
valueSum += NormalizedValue(r) * w; weightSum += w;
|
||||
}
|
||||
return weightSum <= 0 ? null : valueSum / weightSum;
|
||||
}
|
||||
|
||||
private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect)
|
||||
? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
|
||||
: r.Value;
|
||||
|
||||
private static string RatingLabel(int v) => v switch
|
||||
{
|
||||
>= 2 => "++", 1 => "+", 0 => "~", -1 => "−", _ => "−−",
|
||||
};
|
||||
|
||||
private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry));
|
||||
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
|
||||
_entries.Save(entry);
|
||||
point.Homework = entry.Homework;
|
||||
}
|
||||
|
||||
private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Attendance = entry.Attendance switch
|
||||
{
|
||||
null => AttendanceStatus.Present,
|
||||
AttendanceStatus.Present => AttendanceStatus.ExcusePending,
|
||||
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
|
||||
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
|
||||
AttendanceStatus.Unexcused => AttendanceStatus.Truant,
|
||||
AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent,
|
||||
AttendanceStatus.OtherSchoolEvent => null,
|
||||
_ => null,
|
||||
};
|
||||
_entries.Save(entry);
|
||||
// Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen.
|
||||
// Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen.
|
||||
point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance);
|
||||
point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
|
||||
point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,9 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
{
|
||||
private const string AbschnittPrefix = "Abschnitt: ";
|
||||
private const string AbschnittPrefix = ParticipationTimelineBuilder.SectionNotePrefix;
|
||||
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _entries;
|
||||
private readonly IParticipationSectionRepository _sectionRepo;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
||||
@@ -28,12 +24,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
|
||||
private readonly List<Student> _students;
|
||||
private readonly List<ParticipationSession> _allSessions;
|
||||
private readonly List<ParticipationSection> _sectionList;
|
||||
private readonly Dictionary<string, double> _aspectWeights;
|
||||
// Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
|
||||
// Gewichtung, analog zu ParticipationGradeDialogViewModel.
|
||||
private readonly Dictionary<string, ParticipationAspect> _aspectsByKey;
|
||||
private readonly ParticipationTimelineBuilder _timeline;
|
||||
|
||||
public string GroupLabel { get; }
|
||||
|
||||
@@ -79,22 +71,19 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
IGradeRepository grades, IUnitRepository units, GradingService grading,
|
||||
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
|
||||
{
|
||||
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
|
||||
_exams = exams; _results = results; _grades = grades; _units = units; _grading = grading;
|
||||
_sectionRepo = sectionRepo;
|
||||
_grades = grades; _units = units; _grading = grading;
|
||||
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
|
||||
GroupLabel = groupLabel;
|
||||
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
||||
|
||||
var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
|
||||
.GroupBy(a => a.Key).Select(g => g.Last()).ToList();
|
||||
_aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight);
|
||||
_aspectsByKey = applicableAspects.ToDictionary(a => a.Key);
|
||||
_timeline = new ParticipationTimelineBuilder(sessions, entries, aspects, exams, results, grades,
|
||||
_membershipsByStudent, grading, groupId, gradingSystem);
|
||||
var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear);
|
||||
_students = students.GetByGroup(groupId)
|
||||
.Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership)
|
||||
|| GroupMembershipService.Overlaps(membership, schoolYearRange.From, schoolYearRange.To))
|
||||
.OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
|
||||
_allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
|
||||
_sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
|
||||
|
||||
_rollupPeriod = RollupPeriodOptions[0];
|
||||
@@ -131,153 +120,28 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
|
||||
private void BuildTimeline(Guid studentId)
|
||||
{
|
||||
var points = new List<(DateOnly Date, WizardTimelinePoint Point)>();
|
||||
|
||||
foreach (var session in _allSessions)
|
||||
{
|
||||
if (!IsStudentActiveOn(studentId, session.Date)) continue;
|
||||
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
|
||||
points.Add((session.Date, BuildSessionPoint(session, entry, studentId)));
|
||||
}
|
||||
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
|
||||
{
|
||||
if (!IsStudentActiveOn(studentId, exam.Date)) continue;
|
||||
var result = _results.GetByExamAndStudent(exam.Id, studentId);
|
||||
if (result is null) continue;
|
||||
points.Add((exam.Date, BuildExamPoint(exam, result)));
|
||||
}
|
||||
foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
|
||||
.Where(g => g.Category != GradeCategory.Participation)
|
||||
.Where(g => IsStudentActiveOn(studentId, g.Date)))
|
||||
{
|
||||
points.Add((grade.Date, BuildOtherGradePoint(grade)));
|
||||
}
|
||||
points = points.OrderBy(p => p.Date).ToList();
|
||||
var points = _timeline.BuildPoints(studentId);
|
||||
|
||||
Timeline.Clear();
|
||||
foreach (var section in _sectionList)
|
||||
{
|
||||
var group = new WizardSectionGroup($"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}", isOpen: false);
|
||||
foreach (var (date, point) in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
|
||||
var group = new WizardSectionGroup(ParticipationTimelineBuilder.BandLabel(section), isOpen: false);
|
||||
foreach (var point in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
|
||||
group.Points.Add(point);
|
||||
Timeline.Add(group);
|
||||
}
|
||||
|
||||
var openStart = ComputeOpenStart();
|
||||
var openGroup = new WizardSectionGroup($"läuft seit {openStart:dd.MM.}", isOpen: true);
|
||||
foreach (var (date, point) in points.Where(p => p.Date >= openStart))
|
||||
var openGroup = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true);
|
||||
foreach (var point in points.Where(p => p.Date >= openStart))
|
||||
openGroup.Points.Add(point);
|
||||
Timeline.Add(openGroup);
|
||||
}
|
||||
|
||||
private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
|
||||
{
|
||||
var weightedValue = entry is not null ? WeightedRating(entry) : null;
|
||||
var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : "";
|
||||
var note = entry?.Note;
|
||||
var tooltip = $"{session.Date:dd.MM.yyyy}" +
|
||||
(ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
|
||||
(string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
|
||||
|
||||
var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
|
||||
hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
|
||||
{
|
||||
Homework = entry is null ? null : HomeworkDisplay.Effective(entry),
|
||||
AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
|
||||
AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
|
||||
AttendanceColor = AttendanceDisplay.Color(entry?.Attendance),
|
||||
AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value)
|
||||
?? new Dictionary<string, int>(),
|
||||
WeightedValue = weightedValue,
|
||||
OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem),
|
||||
};
|
||||
point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
|
||||
point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
|
||||
return point;
|
||||
}
|
||||
|
||||
private WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
|
||||
{
|
||||
var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
|
||||
return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
|
||||
hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
|
||||
}
|
||||
|
||||
private static WizardTimelinePoint BuildOtherGradePoint(Grade grade)
|
||||
{
|
||||
var category = grade.Category switch
|
||||
{
|
||||
GradeCategory.Oral => "Mündlich",
|
||||
GradeCategory.Homework => "Hausaufgabe",
|
||||
GradeCategory.Project => "Projekt",
|
||||
_ => "Sonstige Leistung",
|
||||
};
|
||||
var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim();
|
||||
var label = $"{detail}: {grade.Value}";
|
||||
return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label,
|
||||
hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}");
|
||||
}
|
||||
|
||||
private double? WeightedRating(ParticipationEntry entry)
|
||||
{
|
||||
if (entry.Ratings.Count == 0) return null;
|
||||
var weightSum = 0.0; var valueSum = 0.0;
|
||||
foreach (var r in entry.Ratings)
|
||||
{
|
||||
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
|
||||
if (w <= 0) continue;
|
||||
valueSum += NormalizedValue(r) * w; weightSum += w;
|
||||
}
|
||||
return weightSum <= 0 ? null : valueSum / weightSum;
|
||||
}
|
||||
|
||||
private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect)
|
||||
? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
|
||||
: r.Value;
|
||||
|
||||
private static string RatingLabel(int v) => v switch
|
||||
{
|
||||
>= 2 => "++", 1 => "+", 0 => "~", -1 => "−", _ => "−−",
|
||||
};
|
||||
|
||||
private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry));
|
||||
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
|
||||
_entries.Save(entry);
|
||||
point.Homework = entry.Homework;
|
||||
}
|
||||
|
||||
private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||
entry.Attendance = entry.Attendance switch
|
||||
{
|
||||
null => AttendanceStatus.Present,
|
||||
AttendanceStatus.Present => AttendanceStatus.ExcusePending,
|
||||
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
|
||||
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
|
||||
AttendanceStatus.Unexcused => AttendanceStatus.Truant,
|
||||
AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent,
|
||||
AttendanceStatus.OtherSchoolEvent => null,
|
||||
_ => null,
|
||||
};
|
||||
_entries.Save(entry);
|
||||
// Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen.
|
||||
// Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen.
|
||||
point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance);
|
||||
point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
|
||||
point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
|
||||
}
|
||||
|
||||
// ── Abschnitte ────────────────────────────────────────────────────────────
|
||||
|
||||
private DateOnly ComputeOpenStart() =>
|
||||
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
|
||||
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
|
||||
_timeline.OpenStart(_sectionList, DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
/// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn
|
||||
/// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich
|
||||
@@ -314,31 +178,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
}
|
||||
|
||||
var openStart = ComputeOpenStart();
|
||||
var suggestion = ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today));
|
||||
var suggestion = _timeline.ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today));
|
||||
Sections.Add(new WizardSectionRow("(läuft)", openStart, DateOnly.FromDateTime(DateTime.Today),
|
||||
suggestion ?? "", isOpen: true));
|
||||
}
|
||||
|
||||
private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
|
||||
{
|
||||
var points = new List<double>();
|
||||
foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end)
|
||||
.Where(s => IsStudentActiveOn(studentId, s.Date)))
|
||||
{
|
||||
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
|
||||
if (entry is null || entry.Ratings.Count == 0) continue;
|
||||
var weightSum = 0.0; var valueSum = 0.0;
|
||||
foreach (var r in entry.Ratings)
|
||||
{
|
||||
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
|
||||
if (w <= 0) continue;
|
||||
valueSum += NormalizedValue(r) * w; weightSum += w;
|
||||
}
|
||||
if (weightSum > 0) points.Add(valueSum / weightSum);
|
||||
}
|
||||
return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
|
||||
}
|
||||
|
||||
private void SaveSectionGrade(WizardSectionRow row)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Value)) { row.StatusMessage = "Wert darf nicht leer sein."; return; }
|
||||
@@ -382,7 +226,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
||||
|
||||
foreach (var student in _students)
|
||||
{
|
||||
var suggestion = ComputeSuggestion(student.Id, start, end);
|
||||
var suggestion = _timeline.ComputeSuggestion(student.Id, start, end);
|
||||
if (suggestion is null) continue;
|
||||
_grades.Save(new Grade
|
||||
{
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Zeugnisnoten-Assistent ───────────────────────────────────────────────────
|
||||
//
|
||||
// Geht die Lerngruppe Schüler für Schüler durch und zieht alles zusammen, was für die
|
||||
// Halbjahres- bzw. Endjahresnote zählt: berechnete Note mit Teilbereichen, Mitarbeitsgraph
|
||||
// (abschnittsweise blätterbar oder als durchgehender Verlauf), Arbeiten und Einzelnoten.
|
||||
// Ergänzt den Zeugnisnoten-Dialog (2.4), ersetzt ihn nicht — beide schreiben dieselben
|
||||
// ReportGrade-Datensätze (gleiche Period-Bezeichnungen), der Dialog bleibt die schnelle Liste.
|
||||
//
|
||||
// Halbjahre sind eigenständig: Leistungen aus dem 1. Halbjahr zählen nicht in die Note des
|
||||
// 2. Halbjahrs, werden dort aber ausgegraut als Kontext gezeigt (Trends). Die Endjahresnote
|
||||
// wird aus den Halbjahresnoten verrechnet, nicht aus allen Einzelleistungen; in Klasse 10 lassen
|
||||
// sich dafür zusätzlich Projekt- und mündliche Prüfung eintragen (siehe ReportGradeCalculator).
|
||||
public partial class ReportGradeAssistantViewModel : ObservableObject
|
||||
{
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
private const int ExamGradeLevel = 10;
|
||||
|
||||
private readonly IReportGradeRepository _reportGrades;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly AttendanceBalanceService _attendanceBalance;
|
||||
private readonly GradingService _grading;
|
||||
private readonly ParticipationTimelineBuilder _timeline;
|
||||
private readonly ReportGradeCalculator _calculator;
|
||||
private readonly Guid _groupId;
|
||||
private readonly int _gradeLevel;
|
||||
private readonly DateOnly _today;
|
||||
|
||||
private readonly List<Student> _allStudents;
|
||||
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
||||
private readonly List<ParticipationSection> _sections;
|
||||
|
||||
private string? _calculatedValue;
|
||||
private bool _showing;
|
||||
private List<SectionView> _sectionViews = [];
|
||||
private int _sectionIndex;
|
||||
private bool _loading;
|
||||
|
||||
public string GroupLabel { get; }
|
||||
|
||||
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||||
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
|
||||
[ObservableProperty] private string _schemeSummary = "";
|
||||
[ObservableProperty] private AssistantStudentItem? _selectedStudent;
|
||||
[ObservableProperty] private bool _showPreviousHalf = true;
|
||||
|
||||
// Kopf: Note des gewählten Schülers
|
||||
[ObservableProperty] private string _calculatedDisplay = "–";
|
||||
[ObservableProperty] private string _calculationDetail = "";
|
||||
[ObservableProperty] private string _borderlineHint = "";
|
||||
[ObservableProperty] private string _absenceRateDisplay = "";
|
||||
[ObservableProperty] private string _absenceRateColorHex = "#8A8A8A";
|
||||
[ObservableProperty] private string? _overrideValue;
|
||||
[ObservableProperty] private string? _overrideReason;
|
||||
[ObservableProperty] private string _overrideReasonError = "";
|
||||
[ObservableProperty] private bool _isLocked;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
// Klasse 10: Prüfungsnoten, die in der Endjahresnote wie ein weiteres Halbjahr zählen
|
||||
[ObservableProperty] private string? _projectExamGrade;
|
||||
[ObservableProperty] private string? _oralExamGrade;
|
||||
[ObservableProperty] private string? _writtenExamGrade;
|
||||
[ObservableProperty] private string? _additionalOralExamGrade;
|
||||
[ObservableProperty] private string _projectExamGradeError = "";
|
||||
[ObservableProperty] private string _oralExamGradeError = "";
|
||||
[ObservableProperty] private string _writtenExamGradeError = "";
|
||||
[ObservableProperty] private string _additionalOralExamGradeError = "";
|
||||
|
||||
// Mitarbeitsgraph
|
||||
[ObservableProperty] private bool _isSectionMode = true;
|
||||
[ObservableProperty] private double _timelineZoom = 1.0;
|
||||
[ObservableProperty] private IReadOnlyList<WizardSectionGroup> _chartGroups = [];
|
||||
[ObservableProperty] private string _sectionTitle = "";
|
||||
[ObservableProperty] private string _sectionPosition = "";
|
||||
|
||||
public List<ParticipationPeriodOption> PeriodOptions { get; } =
|
||||
[
|
||||
new(ParticipationPeriod.H1, "1. Halbjahr"),
|
||||
new(ParticipationPeriod.H2, "2. Halbjahr"),
|
||||
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
|
||||
];
|
||||
public string[] RoundingOptions { get; } = RoundingRuleDisplay.Options;
|
||||
|
||||
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
||||
public string RoundingRuleName
|
||||
{
|
||||
get => RoundingRuleDisplay.Label(RoundingRule);
|
||||
set => RoundingRule = RoundingRuleDisplay.FromLabel(value);
|
||||
}
|
||||
|
||||
public ObservableCollection<AssistantStudentItem> Students { get; } = [];
|
||||
public ObservableCollection<AssistantTile> Tiles { get; } = [];
|
||||
public ObservableCollection<AssistantWorkCard> Works { get; } = [];
|
||||
public ObservableCollection<AssistantGradeRow> ParticipationGrades { get; } = [];
|
||||
public ObservableCollection<AssistantGradeRow> OtherGrades { get; } = [];
|
||||
|
||||
public bool HasStudent => SelectedStudent is not null;
|
||||
public string StudentName => SelectedStudent?.Name ?? "";
|
||||
public string ProgressText => SelectedStudent is null
|
||||
? "" : $"{Students.IndexOf(SelectedStudent) + 1} / {Students.Count}";
|
||||
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
|
||||
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue!.Trim() : CalculatedDisplay;
|
||||
public bool IsContinuousMode => !IsSectionMode;
|
||||
/// Nur im 2. Halbjahr gibt es ein Vorhalbjahr, das als Kontext eingeblendet werden kann.
|
||||
public bool CanShowPreviousHalf => SelectedPeriod.Period == ParticipationPeriod.H2;
|
||||
/// Projekt- und mündliche Prüfung gibt es nur in Klasse 10 und nur für die Endjahresnote.
|
||||
public bool CanEnterExamGrades =>
|
||||
SelectedPeriod.Period == ParticipationPeriod.FullYear && _gradeLevel == ExamGradeLevel;
|
||||
public bool HasWorks => Works.Count > 0;
|
||||
public bool HasParticipationGrades => ParticipationGrades.Count > 0;
|
||||
public bool HasOtherGrades => OtherGrades.Count > 0;
|
||||
|
||||
public ReportGradeAssistantViewModel(IGradeRepository grades, IExamRepository exams,
|
||||
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
|
||||
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
|
||||
IParticipationSessionRepository participationSessions, IParticipationRepository participation,
|
||||
IParticipationAspectRepository aspects, IParticipationSectionRepository sections,
|
||||
AttendanceBalanceService attendanceBalance, GradingService grading,
|
||||
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear,
|
||||
int gradeLevel, DateOnly? today = null)
|
||||
{
|
||||
_reportGrades = reportGrades; _participation = participation;
|
||||
_attendanceBalance = attendanceBalance; _grading = grading;
|
||||
_groupId = groupId; _gradeLevel = gradeLevel;
|
||||
_today = today ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
GroupLabel = groupLabel;
|
||||
|
||||
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
||||
_allStudents = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
|
||||
_sections = sections.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
|
||||
_timeline = new ParticipationTimelineBuilder(participationSessions, participation, aspects, exams, results,
|
||||
grades, _membershipsByStudent, grading, groupId, gradingSystem);
|
||||
_calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
|
||||
_membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear);
|
||||
|
||||
// Vorbelegung: das Halbjahr, in dem wir uns gerade befinden.
|
||||
var (h1From, h1To) = _calculator.Range(ParticipationPeriod.H1);
|
||||
_selectedPeriod = _today >= h1From && _today <= h1To ? PeriodOptions[0] : PeriodOptions[1];
|
||||
Reload();
|
||||
}
|
||||
|
||||
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanShowPreviousHalf));
|
||||
OnPropertyChanged(nameof(CanEnterExamGrades));
|
||||
Reload();
|
||||
}
|
||||
|
||||
partial void OnRoundingRuleChanged(RoundingRule value) => Reload();
|
||||
partial void OnShowPreviousHalfChanged(bool value) => ShowStudent();
|
||||
|
||||
partial void OnSelectedStudentChanged(AssistantStudentItem? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasStudent));
|
||||
OnPropertyChanged(nameof(StudentName));
|
||||
OnPropertyChanged(nameof(ProgressText));
|
||||
PreviousStudentCommand.NotifyCanExecuteChanged();
|
||||
NextStudentCommand.NotifyCanExecuteChanged();
|
||||
if (!_loading) ShowStudent();
|
||||
}
|
||||
|
||||
partial void OnIsSectionModeChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsContinuousMode));
|
||||
ApplyChartMode();
|
||||
}
|
||||
|
||||
partial void OnIsLockedChanged(bool value) => OnPropertyChanged(nameof(LockLabel));
|
||||
partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay));
|
||||
partial void OnCalculatedDisplayChanged(string value) => OnPropertyChanged(nameof(FinalDisplay));
|
||||
|
||||
// Eine eingetippte Prüfungsnote wirkt sofort auf die angezeigte Berechnung, gespeichert wird
|
||||
// sie erst mit der Zeugnisnote.
|
||||
partial void OnProjectExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
|
||||
partial void OnOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
|
||||
partial void OnWrittenExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
|
||||
partial void OnAdditionalOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
|
||||
|
||||
private void ClearErrors()
|
||||
{
|
||||
OverrideReasonError = ""; ProjectExamGradeError = ""; OralExamGradeError = "";
|
||||
WrittenExamGradeError = ""; AdditionalOralExamGradeError = "";
|
||||
}
|
||||
|
||||
// ── Laden ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
_calculator.Reload();
|
||||
SchemeSummary = _calculator.SchemeSummary;
|
||||
|
||||
var (from, to) = _calculator.Range(SelectedPeriod.Period);
|
||||
var previousId = SelectedStudent?.StudentId;
|
||||
|
||||
_loading = true;
|
||||
Students.Clear();
|
||||
foreach (var student in _allStudents)
|
||||
{
|
||||
if (_membershipsByStudent.TryGetValue(student.Id, out var membership)
|
||||
&& !GroupMembershipService.Overlaps(membership, from, to)) continue;
|
||||
var item = new AssistantStudentItem(student.Id, student.FullName);
|
||||
RefreshItem(item);
|
||||
Students.Add(item);
|
||||
}
|
||||
SelectedStudent = Students.FirstOrDefault(s => s.StudentId == previousId) ?? Students.FirstOrDefault();
|
||||
_loading = false;
|
||||
|
||||
OnPropertyChanged(nameof(ProgressText));
|
||||
PreviousStudentCommand.NotifyCanExecuteChanged();
|
||||
NextStudentCommand.NotifyCanExecuteChanged();
|
||||
ShowStudent();
|
||||
}
|
||||
|
||||
private void RefreshItem(AssistantStudentItem item)
|
||||
{
|
||||
var record = _reportGrades.GetByStudentGroupPeriod(item.StudentId, _groupId, SelectedPeriod.Label);
|
||||
var calculated = record is { IsLocked: true }
|
||||
? NullIfEmpty(record.CalculatedValue)
|
||||
: _calculator.Calculate(item.StudentId, SelectedPeriod.Period, RoundingRule).Rounded;
|
||||
item.Update(NullIfEmpty(record?.OverrideValue) ?? calculated ?? "–", record switch
|
||||
{
|
||||
null => AssistantGradeStatus.Open,
|
||||
{ IsLocked: true } => AssistantGradeStatus.Locked,
|
||||
_ => AssistantGradeStatus.Saved,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Berechnung ───────────────────────────────────────────────────────────
|
||||
|
||||
// ── Anzeige des gewählten Schülers ───────────────────────────────────────
|
||||
|
||||
private void ShowStudent()
|
||||
{
|
||||
StatusMessage = "";
|
||||
ClearErrors();
|
||||
Works.Clear(); ParticipationGrades.Clear(); OtherGrades.Clear();
|
||||
|
||||
if (SelectedStudent is null)
|
||||
{
|
||||
_calculatedValue = null;
|
||||
Tiles.Clear();
|
||||
CalculatedDisplay = "–"; CalculationDetail = ""; BorderlineHint = ""; AbsenceRateDisplay = "";
|
||||
OverrideValue = null; OverrideReason = null; IsLocked = false;
|
||||
_sectionViews = [];
|
||||
ApplyChartMode();
|
||||
NotifyLists();
|
||||
return;
|
||||
}
|
||||
|
||||
var studentId = SelectedStudent.StudentId;
|
||||
var (from, to) = _calculator.Range(SelectedPeriod.Period);
|
||||
_membershipsByStudent.TryGetValue(studentId, out var membership);
|
||||
|
||||
var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
|
||||
_showing = true;
|
||||
IsLocked = record is { IsLocked: true };
|
||||
OverrideValue = record?.OverrideValue;
|
||||
OverrideReason = record?.OverrideReason;
|
||||
ProjectExamGrade = CanEnterExamGrades ? record?.ProjectExamGrade : null;
|
||||
OralExamGrade = CanEnterExamGrades ? record?.OralExamGrade : null;
|
||||
WrittenExamGrade = CanEnterExamGrades ? record?.WrittenExamGrade : null;
|
||||
AdditionalOralExamGrade = CanEnterExamGrades ? record?.AdditionalOralExamGrade : null;
|
||||
_showing = false;
|
||||
|
||||
ShowCalculation();
|
||||
ShowAbsenceRate(studentId, from, to);
|
||||
|
||||
// Im 2. Halbjahr auf Wunsch das Vorhalbjahr als Kontext mitzeigen — ohne Einfluss auf die Note.
|
||||
var displayFrom = CanShowPreviousHalf && ShowPreviousHalf
|
||||
? _calculator.Range(ParticipationPeriod.FullYear).From : from;
|
||||
BuildWorks(studentId, membership, displayFrom, from, to);
|
||||
BuildGradeRows(studentId, membership, displayFrom, from, to);
|
||||
BuildSections(studentId, displayFrom, to);
|
||||
NotifyLists();
|
||||
}
|
||||
|
||||
/// Berechnete Note und Teilnoten-Kacheln des gewählten Schülers.
|
||||
private void ShowCalculation()
|
||||
{
|
||||
if (SelectedStudent is null) return;
|
||||
var studentId = SelectedStudent.StudentId;
|
||||
var period = SelectedPeriod.Period;
|
||||
// In Klasse 10 gelten die gerade eingetragenen Prüfungsnoten, sonst die gespeicherten.
|
||||
YearExamGrades? typed = CanEnterExamGrades
|
||||
? new YearExamGrades(ProjectExamGrade, OralExamGrade, WrittenExamGrade, AdditionalOralExamGrade)
|
||||
: null;
|
||||
var result = _calculator.Calculate(studentId, period, RoundingRule, typed);
|
||||
|
||||
// Festgeschrieben heißt: der gespeicherte Stand gilt, es wird nicht neu berechnet.
|
||||
_calculatedValue = IsLocked
|
||||
? NullIfEmpty(_reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label)?.CalculatedValue)
|
||||
: result.Rounded;
|
||||
CalculatedDisplay = _calculatedValue ?? "–";
|
||||
// Genau auf der Grenze entscheidet die gewählte Rundung nur formal — der Hinweis macht
|
||||
// sichtbar, dass hier die Lehrkraft entscheiden sollte.
|
||||
var between = !IsLocked && result.Value is { } value ? ReportGradeCalculator.BetweenGrades(value) : null;
|
||||
BorderlineHint = between is null ? "" : $"Liegt genau {between} — pädagogische Entscheidung.";
|
||||
|
||||
Tiles.Clear();
|
||||
if (period == ParticipationPeriod.FullYear)
|
||||
{
|
||||
var parts = _calculator.YearParts(studentId, RoundingRule, typed);
|
||||
foreach (var part in parts)
|
||||
Tiles.Add(new AssistantTile(part.Label, part.Final ?? "–", part.Hint));
|
||||
CalculationDetail = result.Value is null
|
||||
? "Noch keine Halbjahresnote vorhanden."
|
||||
: $"aus {parts.Count(p => p.Final is not null)} Teilnoten {Format(result.Value.Value)} vor Rundung";
|
||||
}
|
||||
else
|
||||
{
|
||||
var scheme = _calculator.Scheme;
|
||||
Tiles.Add(BucketTile("Schriftlich", result.Written, scheme.ExamsPercent));
|
||||
Tiles.Add(BucketTile("Mitarbeit", result.Participation, scheme.ParticipationPercent));
|
||||
Tiles.Add(BucketTile("Sonstige", result.Other, scheme.OtherPercent));
|
||||
CalculationDetail = result.Value is null
|
||||
? "Noch keine Leistungen im gewählten Zeitraum."
|
||||
: $"gewichtet {Format(result.Value.Value)} vor Rundung";
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyLists()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasWorks));
|
||||
OnPropertyChanged(nameof(HasParticipationGrades));
|
||||
OnPropertyChanged(nameof(HasOtherGrades));
|
||||
}
|
||||
|
||||
private static AssistantTile BucketTile(string label, double? average, double percent) =>
|
||||
new(label, average.HasValue ? Format(average.Value) : "–", $"{percent:0.#} %");
|
||||
|
||||
private void ShowAbsenceRate(Guid studentId, DateOnly from, DateOnly to)
|
||||
{
|
||||
// Wie im Zeugnisnoten-Dialog (2.4.5): nur Sitzungen dieser Gruppe im gewählten Zeitraum.
|
||||
var attendance = _timeline.Sessions
|
||||
.Where(s => s.Date >= from && s.Date <= to)
|
||||
.Select(s => (s.Date, _participation.GetBySessionAndStudent(s.Id, studentId)?.Attendance))
|
||||
.ToList();
|
||||
var rate = _attendanceBalance.Calculate(attendance, from, to).AbsenceRatePercent;
|
||||
AbsenceRateDisplay = $"{rate:0.#} % gefehlt";
|
||||
AbsenceRateColorHex = rate >= ReportGradeRow.HighAbsenceThresholdPercent ? "#D64545" : "#8A8A8A";
|
||||
}
|
||||
|
||||
private void BuildWorks(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to)
|
||||
{
|
||||
foreach (var exam in _calculator.Exams.Where(e => e.Date >= displayFrom && e.Date <= to))
|
||||
{
|
||||
if (!ReportGradeCalculator.ExamApplies(exam, membership)) continue;
|
||||
var results = _calculator.ResultsByExam[exam.Id];
|
||||
results.TryGetValue(studentId, out var own);
|
||||
// Nachschreib- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes
|
||||
// Ergebnis gehören sie nicht in diese Übersicht.
|
||||
if (own is null && exam.RepeatOfExamId.HasValue) continue;
|
||||
|
||||
var gradeDisplay = own switch
|
||||
{
|
||||
null => "–",
|
||||
{ Absent: true } => "abw.",
|
||||
_ => own.Grade ?? "–",
|
||||
};
|
||||
|
||||
var percentDisplay = "";
|
||||
if (own is { Absent: false })
|
||||
{
|
||||
var maximum = ExamScoringService.Maximum(exam.Tasks, own.Variant);
|
||||
if (maximum > 0) percentDisplay = $"{own.TotalPoints / maximum * 100:0} %";
|
||||
}
|
||||
|
||||
var classGrades = results.Values.Where(r => !r.Absent && r.Grade is not null)
|
||||
.Select(r => (r.Grade!, 1.0)).ToList();
|
||||
var classAverage = classGrades.Count > 0 ? $"Ø Kurs {Format(_grading.WeightedAverage(classGrades))}" : "";
|
||||
|
||||
var kind = exam.Kind.Label();
|
||||
if (exam.IsMakeUp) kind += " · Nachschreibarbeit";
|
||||
else if (exam.RepeatOfExamId.HasValue) kind += " · Wiederholung";
|
||||
|
||||
Works.Add(new AssistantWorkCard(exam.Title, exam.Date, kind, gradeDisplay, percentDisplay,
|
||||
classAverage, isContext: exam.Date < from));
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildGradeRows(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to)
|
||||
{
|
||||
foreach (var grade in _calculator.Grades
|
||||
.Where(g => g.StudentId == studentId && g.Date >= displayFrom && g.Date <= to)
|
||||
.Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
|
||||
{
|
||||
var isParticipation = grade.Category == GradeCategory.Participation;
|
||||
var category = GradeCategoryDisplay.Label(grade.Category);
|
||||
var label = string.IsNullOrWhiteSpace(grade.Note)
|
||||
? category
|
||||
: isParticipation ? grade.Note.Trim() : $"{category}: {grade.Note.Trim()}";
|
||||
var row = new AssistantGradeRow(label, grade.Date, grade.Value, isContext: grade.Date < from);
|
||||
(isParticipation ? ParticipationGrades : OtherGrades).Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mitarbeitsgraph: Abschnitte / Verlauf ────────────────────────────────
|
||||
|
||||
private sealed record SectionView(WizardSectionGroup Group, string Title);
|
||||
|
||||
private void BuildSections(Guid studentId, DateOnly displayFrom, DateOnly to)
|
||||
{
|
||||
var points = _timeline.BuildPoints(studentId).Where(p => p.Date >= displayFrom && p.Date <= to).ToList();
|
||||
var sectionGrades = _calculator.Grades
|
||||
.Where(g => g.StudentId == studentId && g.Category == GradeCategory.Participation && g.Note is not null)
|
||||
.ToList();
|
||||
|
||||
var views = new List<SectionView>();
|
||||
foreach (var section in _sections.Where(s => s.EndDate >= displayFrom && s.StartDate <= to))
|
||||
{
|
||||
var group = new WizardSectionGroup(ParticipationTimelineBuilder.BandLabel(section), isOpen: false);
|
||||
foreach (var point in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
|
||||
group.Points.Add(point);
|
||||
var grade = sectionGrades.FirstOrDefault(g =>
|
||||
g.Note == ParticipationTimelineBuilder.SectionNotePrefix + section.Label)?.Value;
|
||||
views.Add(new SectionView(group,
|
||||
$"{section.Label} · {section.StartDate:dd.MM.}–{section.EndDate:dd.MM.} · " +
|
||||
(grade is null ? "keine Abschnittsnote" : $"Abschnittsnote {grade}")));
|
||||
}
|
||||
|
||||
// Der laufende Abschnitt endet erst mit dem nächsten Abschluss im Mitarbeits-Assistenten;
|
||||
// bis dahin gibt es nur einen Vorschlag aus den bisherigen Sitzungen.
|
||||
var openStart = _timeline.OpenStart(_sections, _today);
|
||||
if (openStart <= to)
|
||||
{
|
||||
var group = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true);
|
||||
foreach (var point in points.Where(p => p.Date >= openStart))
|
||||
group.Points.Add(point);
|
||||
var end = _today < to ? _today : to;
|
||||
var suggestion = _timeline.ComputeSuggestion(studentId, openStart < displayFrom ? displayFrom : openStart, end);
|
||||
views.Add(new SectionView(group,
|
||||
$"Laufender Abschnitt seit {openStart:dd.MM.} · " +
|
||||
(suggestion is null ? "noch kein Vorschlag" : $"vorläufig {suggestion}")));
|
||||
}
|
||||
|
||||
_sectionViews = views;
|
||||
// Beim Schülerwechsel im selben Abschnitt bleiben — so lassen sich Schüler abschnittsweise vergleichen.
|
||||
_sectionIndex = Math.Clamp(_sectionIndex, 0, Math.Max(0, views.Count - 1));
|
||||
ApplyChartMode();
|
||||
}
|
||||
|
||||
private void ApplyChartMode()
|
||||
{
|
||||
if (_sectionViews.Count == 0)
|
||||
{
|
||||
ChartGroups = [];
|
||||
SectionTitle = ""; SectionPosition = "";
|
||||
}
|
||||
else if (IsSectionMode)
|
||||
{
|
||||
var view = _sectionViews[_sectionIndex];
|
||||
ChartGroups = [view.Group];
|
||||
SectionTitle = view.Title;
|
||||
SectionPosition = $"{_sectionIndex + 1} / {_sectionViews.Count}";
|
||||
}
|
||||
else
|
||||
{
|
||||
ChartGroups = _sectionViews.Select(v => v.Group).ToList();
|
||||
SectionTitle = ""; SectionPosition = "";
|
||||
}
|
||||
PreviousSectionCommand.NotifyCanExecuteChanged();
|
||||
NextSectionCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private bool CanGoPreviousSection => IsSectionMode && _sectionIndex > 0;
|
||||
private bool CanGoNextSection => IsSectionMode && _sectionIndex < _sectionViews.Count - 1;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoPreviousSection))]
|
||||
private void PreviousSection() { _sectionIndex--; ApplyChartMode(); }
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoNextSection))]
|
||||
private void NextSection() { _sectionIndex++; ApplyChartMode(); }
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
|
||||
private int StudentIndex => SelectedStudent is null ? -1 : Students.IndexOf(SelectedStudent);
|
||||
private bool CanGoPreviousStudent => StudentIndex > 0;
|
||||
private bool CanGoNextStudent => StudentIndex >= 0 && StudentIndex < Students.Count - 1;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoPreviousStudent))]
|
||||
private void PreviousStudent() => SelectedStudent = Students[StudentIndex - 1];
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoNextStudent))]
|
||||
private void NextStudent() => SelectedStudent = Students[StudentIndex + 1];
|
||||
|
||||
// ── Speichern / Festschreiben ────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void Save() => TrySave();
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveAndNext()
|
||||
{
|
||||
if (!TrySave()) return;
|
||||
if (CanGoNextStudent) NextStudent();
|
||||
}
|
||||
|
||||
private bool TrySave()
|
||||
{
|
||||
if (SelectedStudent is null || IsLocked) return false;
|
||||
ClearErrors();
|
||||
var valid = true;
|
||||
if (!string.IsNullOrWhiteSpace(OverrideValue) && string.IsNullOrWhiteSpace(OverrideReason))
|
||||
{
|
||||
OverrideReasonError = "Für ein manuelles Übersteuern ist eine Begründung Pflicht.";
|
||||
valid = false;
|
||||
}
|
||||
if (CanEnterExamGrades)
|
||||
{
|
||||
if (!IsGradeOrEmpty(ProjectExamGrade)) { ProjectExamGradeError = "Keine gültige Note."; valid = false; }
|
||||
if (!IsGradeOrEmpty(OralExamGrade)) { OralExamGradeError = "Keine gültige Note."; valid = false; }
|
||||
if (!IsGradeOrEmpty(WrittenExamGrade)) { WrittenExamGradeError = "Keine gültige Note."; valid = false; }
|
||||
if (!IsGradeOrEmpty(AdditionalOralExamGrade)) { AdditionalOralExamGradeError = "Keine gültige Note."; valid = false; }
|
||||
|
||||
// Ein Fach ist entweder schriftliches oder mündliches Prüfungsfach; eine zusätzliche
|
||||
// mündliche Prüfung gibt es nur im schriftlichen Prüfungsfach oder ganz ohne Abschlussprüfung.
|
||||
var hasOral = NullIfEmpty(OralExamGrade) is not null;
|
||||
if (hasOral && NullIfEmpty(WrittenExamGrade) is not null)
|
||||
{
|
||||
OralExamGradeError = "Entweder schriftliche oder mündliche Abschlussprüfung.";
|
||||
valid = false;
|
||||
}
|
||||
if (hasOral && NullIfEmpty(AdditionalOralExamGrade) is not null)
|
||||
{
|
||||
AdditionalOralExamGradeError = "Im Fach der mündlichen Abschlussprüfung nicht vorgesehen.";
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (!valid) return false;
|
||||
|
||||
var record = _reportGrades.GetByStudentGroupPeriod(SelectedStudent.StudentId, _groupId, SelectedPeriod.Label)
|
||||
?? new ReportGrade { StudentId = SelectedStudent.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label };
|
||||
record.CalculatedValue = _calculatedValue ?? "";
|
||||
record.OverrideValue = NullIfEmpty(OverrideValue);
|
||||
record.OverrideReason = NullIfEmpty(OverrideReason);
|
||||
if (CanEnterExamGrades)
|
||||
{
|
||||
record.ProjectExamGrade = NullIfEmpty(ProjectExamGrade);
|
||||
record.OralExamGrade = NullIfEmpty(OralExamGrade);
|
||||
record.WrittenExamGrade = NullIfEmpty(WrittenExamGrade);
|
||||
record.AdditionalOralExamGrade = NullIfEmpty(AdditionalOralExamGrade);
|
||||
}
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
_reportGrades.Save(record);
|
||||
|
||||
RefreshItem(SelectedStudent);
|
||||
StatusMessage = "Gespeichert.";
|
||||
return true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLock()
|
||||
{
|
||||
if (SelectedStudent is null) return;
|
||||
var studentId = SelectedStudent.StudentId;
|
||||
var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
|
||||
if (!IsLocked)
|
||||
{
|
||||
// Festschreiben sichert immer zuerst den aktuell angezeigten Stand.
|
||||
if (!TrySave()) return;
|
||||
record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
|
||||
}
|
||||
if (record is null) return;
|
||||
|
||||
record.IsLocked = !record.IsLocked;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
_reportGrades.Save(record);
|
||||
RefreshItem(SelectedStudent);
|
||||
ShowStudent();
|
||||
StatusMessage = record.IsLocked ? "Festgeschrieben." : "Entsperrt.";
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
/// Ganze Note bzw. Punktzahl, optional mit Tendenz ("3+", "2-") — dieselbe Schreibweise, die
|
||||
/// die Notenberechnung versteht; alles andere würde dort stillschweigend ausgelassen.
|
||||
private static bool IsGradeOrEmpty(string? value)
|
||||
{
|
||||
var text = NullIfEmpty(value);
|
||||
return text is null || int.TryParse(text.TrimEnd('+', '-').TrimEnd(), out _);
|
||||
}
|
||||
|
||||
private static string Format(double value) => value.ToString("0.00", German);
|
||||
}
|
||||
|
||||
// ── Bausteine ────────────────────────────────────────────────────────────────
|
||||
|
||||
public enum AssistantGradeStatus { Open, Saved, Locked }
|
||||
|
||||
public partial class AssistantStudentItem(Guid studentId, string name) : ObservableObject
|
||||
{
|
||||
public Guid StudentId { get; } = studentId;
|
||||
public string Name { get; } = name;
|
||||
|
||||
[ObservableProperty] private string _gradeDisplay = "–";
|
||||
[ObservableProperty] private AssistantGradeStatus _status;
|
||||
|
||||
public string StatusLabel => Status switch
|
||||
{
|
||||
AssistantGradeStatus.Locked => "festgeschrieben",
|
||||
AssistantGradeStatus.Saved => "gespeichert",
|
||||
_ => "offen",
|
||||
};
|
||||
|
||||
public string StatusColorHex => Status switch
|
||||
{
|
||||
AssistantGradeStatus.Locked => "#2E9D57",
|
||||
AssistantGradeStatus.Saved => "#2E86DE",
|
||||
_ => "#8A8A8A",
|
||||
};
|
||||
|
||||
public void Update(string gradeDisplay, AssistantGradeStatus status)
|
||||
{
|
||||
GradeDisplay = gradeDisplay;
|
||||
Status = status;
|
||||
}
|
||||
|
||||
partial void OnStatusChanged(AssistantGradeStatus value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(StatusColorHex));
|
||||
}
|
||||
}
|
||||
|
||||
public class AssistantTile(string label, string value, string hint)
|
||||
{
|
||||
public string Label { get; } = label;
|
||||
public string Value { get; } = value;
|
||||
public string Hint { get; } = hint;
|
||||
}
|
||||
|
||||
public class AssistantWorkCard(string title, DateOnly date, string kindLabel, string gradeDisplay,
|
||||
string percentDisplay, string classAverageDisplay, bool isContext)
|
||||
{
|
||||
public string Title { get; } = title;
|
||||
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
|
||||
public string KindLabel { get; } = kindLabel;
|
||||
public string GradeDisplay { get; } = gradeDisplay;
|
||||
public string PercentDisplay { get; } = percentDisplay;
|
||||
public string ClassAverageDisplay { get; } = classAverageDisplay;
|
||||
/// Vorhalbjahr: nur zum Vergleich gezeigt, zählt nicht in die aktuelle Note.
|
||||
public bool IsContext { get; } = isContext;
|
||||
public double DisplayOpacity => IsContext ? 0.45 : 1.0;
|
||||
public string ContextLabel => IsContext ? "1. Halbjahr · zählt nicht" : "";
|
||||
}
|
||||
|
||||
public class AssistantGradeRow(string label, DateOnly date, string value, bool isContext)
|
||||
{
|
||||
public string Label { get; } = label;
|
||||
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
|
||||
public string Value { get; } = value;
|
||||
public bool IsContext { get; } = isContext;
|
||||
public double DisplayOpacity => IsContext ? 0.45 : 1.0;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
public sealed record ReportGradeResult(double? Value, string? Rounded, double? Written, double? Participation, double? Other);
|
||||
|
||||
/// Eine Teilnote der Endjahresnote: Halbjahresnote oder Prüfungsleistung.
|
||||
/// <param name="Value">Rechenwert, wenn er sich nicht aus <paramref name="Final"/> ergibt —
|
||||
/// etwa eine Prüfungsleistung, die zwischen zwei Noten liegt.</param>
|
||||
public sealed record YearPart(string Label, string? Final, string Hint, double Weight = 1.0, double? Value = null);
|
||||
|
||||
/// Prüfungsnoten der Klasse 10, soweit in diesem Fach abgelegt.
|
||||
public readonly record struct YearExamGrades(string? Project, string? Oral,
|
||||
string? Written = null, string? AdditionalOral = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsame Zeugnisnoten-Berechnung für Zeugnisnoten-Dialog (2.4) und Zeugnisnoten-Assistent
|
||||
/// (2.7), damit beide für denselben Datensatz denselben Wert zeigen.
|
||||
///
|
||||
/// Halbjahre sind eigenständig und werden aus den Einzelleistungen ihres Zeitraums gewichtet.
|
||||
/// Die Endjahresnote wird dagegen aus den Halbjahresnoten verrechnet (gleich gewichtet).
|
||||
///
|
||||
/// Klasse 10 (Bremen): In einem Prüfungsfach ergibt sich die Gesamtnote zu zwei Dritteln aus der
|
||||
/// unterrichtlichen Leistung und zu einem Drittel aus der Prüfungsleistung (§ 14 Abs. 3 der
|
||||
/// Verordnung über die Sekundarstufe I der Oberschule) — die Prüfung zählt also wie ein drittes
|
||||
/// Halbjahr. Die Prüfungsleistung ist die Note der schriftlichen bzw. mündlichen
|
||||
/// Abschlussprüfung. Kommt in einem schriftlichen Prüfungsfach eine zusätzliche mündliche
|
||||
/// Prüfung hinzu, werden beide 1:1 verrechnet (§ 13 Abs. 2 PrüfV Sek. I). In einem Fach ohne
|
||||
/// Abschlussprüfung zählt eine zusätzliche mündliche Prüfung selbst als Prüfungsleistung, also
|
||||
/// 2:1 (§ 13 Abs. 3 PrüfV Sek. I). Die Projektprüfung zählt nach Schulpraxis als weiteres
|
||||
/// Halbjahr.
|
||||
///
|
||||
/// Bewusst nicht umgesetzt sind die Rundungsvorgaben der Verordnungen: Noten sind
|
||||
/// Leistungsbewertungen, die Entscheidung zwischen zwei Noten ist pädagogisch. Eine
|
||||
/// Prüfungsleistung zwischen zwei Noten geht deshalb ungerundet in die Rechnung ein, und für die
|
||||
/// Endnote gilt die von der Lehrkraft gewählte Rundung samt Möglichkeit zum Übersteuern.
|
||||
/// </summary>
|
||||
public sealed class ReportGradeCalculator
|
||||
{
|
||||
public const string ProjectExamLabel = "Projektprüfung";
|
||||
public const string OralExamLabel = "Mündliche Prüfung";
|
||||
public const string WrittenExamLabel = "Schriftliche Prüfung";
|
||||
public const string AdditionalOralExamLabel = "Zusätzliche mündliche Prüfung";
|
||||
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IGradingSchemeRepository _schemes;
|
||||
private readonly IReportGradeRepository _reportGrades;
|
||||
private readonly IReadOnlyDictionary<Guid, GroupMembership> _membershipsByStudent;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Guid _groupId;
|
||||
private readonly GroupType _groupType;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly string _schoolYear;
|
||||
|
||||
public GradingScheme Scheme { get; private set; } = new();
|
||||
public List<Exam> Exams { get; private set; } = [];
|
||||
public Dictionary<Guid, Dictionary<Guid, ExamResult>> ResultsByExam { get; private set; } = [];
|
||||
public List<Grade> Grades { get; private set; } = [];
|
||||
|
||||
public string SchemeSummary =>
|
||||
$"Klausuren {Scheme.ExamsPercent:0.#} % · Mitarbeit {Scheme.ParticipationPercent:0.#} % · " +
|
||||
$"Sonstige {Scheme.OtherPercent:0.#} %";
|
||||
|
||||
public ReportGradeCalculator(IGradeRepository grades, IExamRepository exams, IExamResultRepository results,
|
||||
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
|
||||
IReadOnlyDictionary<Guid, GroupMembership> membershipsByStudent, GradingService grading,
|
||||
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string schoolYear)
|
||||
{
|
||||
_grades = grades; _exams = exams; _results = results; _schemes = schemes;
|
||||
_reportGrades = reportGrades; _membershipsByStudent = membershipsByStudent; _grading = grading;
|
||||
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _schoolYear = schoolYear;
|
||||
Reload();
|
||||
}
|
||||
|
||||
/// Liest Schema, Arbeiten und Noten neu ein — vor jeder Neuberechnung einer ganzen Ansicht.
|
||||
public void Reload()
|
||||
{
|
||||
Scheme = _schemes.GetByGroup(_groupId)
|
||||
?? _schemes.GetDefaultForType(_groupType)
|
||||
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
Exams = _exams.GetByGroup(_groupId).OrderBy(e => e.Date).ToList();
|
||||
ResultsByExam = Exams.ToDictionary(e => e.Id,
|
||||
e => _results.GetByExam(e.Id).GroupBy(r => r.StudentId).ToDictionary(g => g.Key, g => g.First()));
|
||||
Grades = _grades.GetByGroup(_groupId).OrderBy(g => g.Date).ToList();
|
||||
}
|
||||
|
||||
public (DateOnly From, DateOnly To) Range(ParticipationPeriod period) =>
|
||||
GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
|
||||
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
|
||||
_ => SchoolYearPeriodKind.FullYear,
|
||||
});
|
||||
|
||||
public static string PeriodLabel(ParticipationPeriod period) => period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => "1. Halbjahr",
|
||||
ParticipationPeriod.H2 => "2. Halbjahr",
|
||||
_ => "Gesamtes Schuljahr",
|
||||
};
|
||||
|
||||
public static bool ExamApplies(Exam exam, GroupMembership? membership)
|
||||
{
|
||||
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) return false;
|
||||
return !exam.Niveau.HasValue || membership?.Niveau == exam.Niveau;
|
||||
}
|
||||
|
||||
/// <param name="examGrades">Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne
|
||||
/// Angabe gelten die am Jahres-Datensatz gespeicherten.</param>
|
||||
public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding,
|
||||
YearExamGrades? examGrades = null)
|
||||
{
|
||||
if (period == ParticipationPeriod.FullYear)
|
||||
{
|
||||
var parts = YearParts(studentId, rounding, examGrades)
|
||||
.Select(p => (Value: p.Value ?? (p.Final is null ? null : GradingService.ParseGradeValue(p.Final)), p.Weight))
|
||||
.Where(p => p.Value.HasValue).ToList();
|
||||
if (parts.Count == 0) return new ReportGradeResult(null, null, null, null, null);
|
||||
var mean = parts.Sum(p => p.Value!.Value * p.Weight) / parts.Sum(p => p.Weight);
|
||||
return new ReportGradeResult(mean, _grading.RoundToGrade(mean, _gradingSystem, rounding), null, null, null);
|
||||
}
|
||||
|
||||
var (from, to) = Range(period);
|
||||
_membershipsByStudent.TryGetValue(studentId, out var membership);
|
||||
|
||||
var examEntries = new List<(Exam Exam, string Grade)>();
|
||||
foreach (var exam in Exams.Where(e => e.Date >= from && e.Date <= to))
|
||||
{
|
||||
if (!ExamApplies(exam, membership)) continue;
|
||||
if (!ResultsByExam[exam.Id].TryGetValue(studentId, out var result)) continue;
|
||||
if (result.Absent || result.Grade is null) continue;
|
||||
examEntries.Add((exam, result.Grade));
|
||||
}
|
||||
var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
|
||||
var written = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
|
||||
var studentGrades = Grades
|
||||
.Where(g => g.StudentId == studentId && g.Date >= from && g.Date <= to)
|
||||
.Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))
|
||||
.ToList();
|
||||
var participation = studentGrades.Where(g => g.Category == GradeCategory.Participation)
|
||||
.Select(g => (Grade: g.Value, g.Weight)).ToList();
|
||||
var other = studentGrades.Where(g => g.Category != GradeCategory.Participation)
|
||||
.Select(g => (Grade: g.Value, g.Weight)).ToList();
|
||||
// Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
|
||||
other.AddRange(testGrades.Select(grade => (Grade: grade, Weight: 1.0)));
|
||||
|
||||
var value = _grading.CalculateReportGradeValue(written, participation, other, Scheme);
|
||||
return new ReportGradeResult(value,
|
||||
value is null ? null : _grading.RoundToGrade(value.Value, _gradingSystem, rounding),
|
||||
written.Count > 0 ? _grading.WeightedAverage(written) : null,
|
||||
participation.Count > 0 ? _grading.WeightedAverage(participation) : null,
|
||||
other.Count > 0 ? _grading.WeightedAverage(other) : null);
|
||||
}
|
||||
|
||||
/// Die Teilnoten der Endjahresnote. Bei den Halbjahren geht eine gespeicherte Entscheidung
|
||||
/// (Übersteuerung bzw. festgeschriebener Wert) der Live-Berechnung vor.
|
||||
public List<YearPart> YearParts(Guid studentId, RoundingRule rounding, YearExamGrades? examGrades = null)
|
||||
{
|
||||
var parts = new List<YearPart>();
|
||||
foreach (var half in new[] { ParticipationPeriod.H1, ParticipationPeriod.H2 })
|
||||
{
|
||||
var label = PeriodLabel(half);
|
||||
var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, label);
|
||||
var overridden = NullIfEmpty(record?.OverrideValue);
|
||||
if (overridden is not null)
|
||||
parts.Add(new YearPart(label, overridden, record!.IsLocked ? "festgeschrieben" : "übersteuert"));
|
||||
else if (record is { IsLocked: true })
|
||||
parts.Add(new YearPart(label, NullIfEmpty(record.CalculatedValue), "festgeschrieben"));
|
||||
else
|
||||
parts.Add(new YearPart(label, Calculate(studentId, half, rounding).Rounded, "berechnet"));
|
||||
}
|
||||
|
||||
// Die unterrichtliche Leistung zählt immer doppelt gegenüber einer Prüfung — auch wenn
|
||||
// nur eine der beiden Halbjahresnoten vorliegt.
|
||||
if (parts.Count(p => p.Final is not null) == 1)
|
||||
parts = parts.Select(p => p.Final is null ? p : p with { Weight = 2.0 }).ToList();
|
||||
|
||||
var exams = examGrades ?? StoredExamGrades(studentId);
|
||||
if (NullIfEmpty(exams.Project) is { } project)
|
||||
parts.Add(new YearPart(ProjectExamLabel, project, "zählt wie ein Halbjahr"));
|
||||
if (ExamPerformance(exams) is { } performance)
|
||||
parts.Add(performance);
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// Die Prüfungsleistung des Fachs, die mit einem Drittel in die Gesamtnote eingeht.
|
||||
private YearPart? ExamPerformance(YearExamGrades exams)
|
||||
{
|
||||
var written = NullIfEmpty(exams.Written);
|
||||
var oral = NullIfEmpty(exams.Oral);
|
||||
var additional = NullIfEmpty(exams.AdditionalOral);
|
||||
|
||||
if (written is not null && additional is not null)
|
||||
{
|
||||
// § 13 Abs. 2 PrüfV Sek. I: 1:1. Liegt das Ergebnis zwischen zwei Noten, wird hier
|
||||
// nicht gerundet — es geht so in die Endnote ein, entschieden wird dort.
|
||||
var mean = _grading.WeightedAverage([(written, 1.0), (additional, 1.0)]);
|
||||
var between = BetweenGrades(mean);
|
||||
return new YearPart("Prüfungsleistung", mean.ToString("0.##", German),
|
||||
$"schriftlich {written} · mündlich {additional}" + (between is null ? "" : $" — {between}"),
|
||||
Value: mean);
|
||||
}
|
||||
if (written is not null) return new YearPart(WrittenExamLabel, written, "ein Drittel der Note");
|
||||
if (oral is not null) return new YearPart(OralExamLabel, oral, "ein Drittel der Note");
|
||||
if (additional is not null) return new YearPart(AdditionalOralExamLabel, additional, "ein Drittel der Note");
|
||||
return null;
|
||||
}
|
||||
|
||||
public YearExamGrades StoredExamGrades(Guid studentId)
|
||||
{
|
||||
var year = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, PeriodLabel(ParticipationPeriod.FullYear));
|
||||
return new YearExamGrades(year?.ProjectExamGrade, year?.OralExamGrade,
|
||||
year?.WrittenExamGrade, year?.AdditionalOralExamGrade);
|
||||
}
|
||||
|
||||
/// "zwischen 4 und 5", wenn der Wert genau auf der Grenze zweier Noten liegt — dann gibt
|
||||
/// es rechnerisch keine richtige Seite, nur eine pädagogische Entscheidung.
|
||||
public static string? BetweenGrades(double value)
|
||||
{
|
||||
var lower = Math.Floor(value);
|
||||
return Math.Abs(value - lower - 0.5) < 0.0001 ? $"zwischen {lower:0} und {lower + 1:0}" : null;
|
||||
}
|
||||
|
||||
private static readonly System.Globalization.CultureInfo German =
|
||||
System.Globalization.CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -13,22 +13,15 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly IGradingSchemeRepository _schemes;
|
||||
private readonly IReportGradeRepository _reportGrades;
|
||||
private readonly ReportGradeCalculator _calculator;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly AttendanceBalanceService _attendanceBalance;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Guid _groupId;
|
||||
private readonly GroupType _groupType;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly string _groupLabel;
|
||||
private readonly string _schoolYear;
|
||||
|
||||
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
|
||||
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
|
||||
@@ -59,12 +52,13 @@ public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
AttendanceBalanceService attendanceBalance, GradingService grading,
|
||||
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
|
||||
{
|
||||
_grades = grades; _exams = exams; _results = results; _students = students;
|
||||
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
|
||||
_students = students; _memberships = memberships; _reportGrades = reportGrades;
|
||||
_participationSessions = participationSessions; _participation = participation;
|
||||
_attendanceBalance = attendanceBalance;
|
||||
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
|
||||
_schoolYear = schoolYear;
|
||||
_groupId = groupId; _groupLabel = groupLabel;
|
||||
_calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
|
||||
memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId), grading,
|
||||
groupId, groupType, gradingSystem, schoolYear);
|
||||
|
||||
_selectedPeriod = PeriodOptions[0];
|
||||
Recompute();
|
||||
@@ -73,31 +67,19 @@ public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
|
||||
partial void OnRoundingRuleChanged(RoundingRule value) => Recompute();
|
||||
|
||||
private GradingScheme ResolveScheme() =>
|
||||
_schemes.GetByGroup(_groupId)
|
||||
?? _schemes.GetDefaultForType(_groupType)
|
||||
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
public void Refresh() => Recompute();
|
||||
|
||||
private void Recompute()
|
||||
{
|
||||
var scheme = ResolveScheme();
|
||||
SchemeSummary = $"Klausuren {scheme.ExamsPercent:0.#} % · Mitarbeit {scheme.ParticipationPercent:0.#} % · " +
|
||||
$"Sonstige {scheme.OtherPercent:0.#} %";
|
||||
_calculator.Reload();
|
||||
SchemeSummary = _calculator.SchemeSummary;
|
||||
|
||||
var period = SelectedPeriod.Period;
|
||||
var periodTag = SelectedPeriod.Label;
|
||||
var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
|
||||
{
|
||||
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
|
||||
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
|
||||
_ => SchoolYearPeriodKind.FullYear,
|
||||
});
|
||||
var (periodFrom, periodTo) = _calculator.Range(period);
|
||||
|
||||
var students = _students.GetByGroup(_groupId);
|
||||
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
|
||||
var exams = _exams.GetByGroup(_groupId).Where(e => e.Date >= periodFrom && e.Date <= periodTo).ToList();
|
||||
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
|
||||
var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList();
|
||||
|
||||
// Fehlquote (Nutzer-Feedback): je Schüler die Anwesenheits-Bilanz im gewählten Zeitraum,
|
||||
// nur aus Sitzungen dieser Gruppe (anders als StudentDetailViewModel.LoadAttendanceBalance,
|
||||
@@ -132,31 +114,9 @@ public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
continue;
|
||||
}
|
||||
|
||||
var examEntries = new List<(Exam Exam, string Grade)>();
|
||||
foreach (var exam in exams)
|
||||
{
|
||||
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
|
||||
if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
|
||||
if (result.Absent || result.Grade is null) continue;
|
||||
examEntries.Add((exam, result.Grade));
|
||||
}
|
||||
var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
|
||||
var examGrades = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
|
||||
var participationGrades = allGrades
|
||||
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
|
||||
&& (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
|
||||
.Select(g => (g.Value, g.Weight)).ToList();
|
||||
var otherGrades = allGrades
|
||||
.Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation
|
||||
&& (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
|
||||
.Select(g => (g.Value, g.Weight)).ToList();
|
||||
// Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
|
||||
otherGrades.AddRange(testGrades.Select(grade => (grade, 1.0)));
|
||||
|
||||
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
|
||||
scheme, _gradingSystem, RoundingRule);
|
||||
// Halbjahre aus den Einzelleistungen, das Schuljahr aus den Halbjahresnoten
|
||||
// (plus ggf. Prüfungsnoten) — siehe ReportGradeCalculator.
|
||||
var calculated = _calculator.Calculate(student.Id, period, RoundingRule).Rounded;
|
||||
|
||||
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, absenceRate, Save, ToggleLock));
|
||||
}
|
||||
|
||||
@@ -75,11 +75,33 @@ public partial class GradeOverviewTabView : UserControl
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear);
|
||||
|
||||
var dialog = new ReportGradeDialog { DataContext = dialogVm };
|
||||
var dialog = new ReportGradeDialog { DataContext = dialogVm, OnOpenAssistant = ShowReportGradeAssistant };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is not null) await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async Task ShowReportGradeAssistant(Window owner)
|
||||
{
|
||||
var dialogVm = new ReportGradeAssistantViewModel(
|
||||
App.Services.GetRequiredService<IGradeRepository>(),
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGradingSchemeRepository>(),
|
||||
App.Services.GetRequiredService<IReportGradeRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationAspectRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSectionRepository>(),
|
||||
App.Services.GetRequiredService<AttendanceBalanceService>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear,
|
||||
App.Services.GetRequiredService<IGroupRepository>().GetById(_vm.GroupId)?.GradeLevel ?? 0);
|
||||
|
||||
await new ReportGradeAssistantDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async Task ShowPerformanceOverviewDialog(GradeOverviewRow row)
|
||||
{
|
||||
var dialogVm = new StudentPerformanceOverviewViewModel(
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.ReportGradeAssistantDialog"
|
||||
x:DataType="vm:ReportGradeAssistantViewModel"
|
||||
Title="Zeugnisnoten-Assistent"
|
||||
Width="1280" Height="880" MinWidth="980" MinHeight="640"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid ColumnDefinitions="250,*" RowDefinitions="*,Auto" Margin="24">
|
||||
|
||||
<!-- Links: Zeitraum und Schülerliste -->
|
||||
<Grid Grid.Column="0" Grid.Row="0" RowDefinitions="Auto,Auto,*" Margin="0,0,18,0">
|
||||
<StackPanel Grid.Row="0" Spacing="2">
|
||||
<TextBlock Text="{Binding GroupLabel}" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding SchemeSummary}" FontSize="11" Opacity="0.6" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="6" Margin="0,12,0,10">
|
||||
<ComboBox ItemsSource="{Binding PeriodOptions}" SelectedItem="{Binding SelectedPeriod}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="6">
|
||||
<TextBlock Grid.Column="0" Text="Rundung:" VerticalAlignment="Center" FontSize="12"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding RoundingOptions}"
|
||||
SelectedItem="{Binding RoundingRuleName}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<CheckBox Content="1. Halbjahr als Kontext zeigen" IsChecked="{Binding ShowPreviousHalf}"
|
||||
IsVisible="{Binding CanShowPreviousHalf}" FontSize="12"
|
||||
ToolTip.Tip="Zeigt Arbeiten, Noten und Mitarbeit des 1. Halbjahrs ausgegraut zum Vergleich. Sie zählen nicht in die Note des 2. Halbjahrs."/>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox Grid.Row="2" ItemsSource="{Binding Students}" SelectedItem="{Binding SelectedStudent}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AssistantStudentItem">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding StatusLabel}" FontSize="10" Foreground="{Binding StatusColorHex}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding GradeDisplay}" FontSize="15" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
|
||||
<!-- Rechts: der gewählte Schüler -->
|
||||
<TextBlock Grid.Column="1" Grid.Row="0" Text="Keine Schüler im gewählten Zeitraum."
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.35" FontSize="14"
|
||||
IsVisible="{Binding !HasStudent}"/>
|
||||
|
||||
<ScrollViewer Grid.Column="1" Grid.Row="0" IsVisible="{Binding HasStudent}">
|
||||
<StackPanel Spacing="14" Margin="0,0,12,0">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding StudentName}" Classes="dialogtitle"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="◂ Zurück" Command="{Binding PreviousStudentCommand}"/>
|
||||
<TextBlock Text="{Binding ProgressText}" VerticalAlignment="Center" Opacity="0.6" FontSize="12"/>
|
||||
<Button Content="Weiter ▸" Command="{Binding NextStudentCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Note -->
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="14">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<StackPanel Grid.Column="0" Margin="0,0,22,0">
|
||||
<TextBlock Text="Berechnet" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding CalculatedDisplay}" FontSize="34" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding CalculationDetail}" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding BorderlineHint}" FontSize="11" Foreground="#D98200" FontWeight="SemiBold"
|
||||
MaxWidth="190" TextWrapping="Wrap"
|
||||
IsVisible="{Binding BorderlineHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl Grid.Column="1" ItemsSource="{Binding Tiles}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AssistantTile">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,6" Margin="0,0,8,4" MinWidth="104">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding Value}" FontSize="18" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Hint}" FontSize="10" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<StackPanel Grid.Column="2" HorizontalAlignment="Right">
|
||||
<TextBlock Text="Zeugnisnote" FontSize="11" Opacity="0.6" HorizontalAlignment="Right"/>
|
||||
<TextBlock Text="{Binding FinalDisplay}" FontSize="34" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Right"/>
|
||||
<TextBlock Text="{Binding AbsenceRateDisplay}" FontSize="11" HorizontalAlignment="Right"
|
||||
Foreground="{Binding AbsenceRateColorHex}"
|
||||
ToolTip.Tip="Fehlquote im gewählten Zeitraum — ab 50 % darf laut Schulrecht unabhängig von der fachlichen Leistung eine 5 vergeben werden."/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="150,*,Auto" ColumnSpacing="6">
|
||||
<TextBox Grid.Column="0" Text="{Binding OverrideValue}" PlaceholderText="Übersteuern (optional)"
|
||||
IsEnabled="{Binding !IsLocked}"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding OverrideReason}" PlaceholderText="Begründung"
|
||||
IsEnabled="{Binding !IsLocked}"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="Speichern" Command="{Binding SaveCommand}" IsEnabled="{Binding !IsLocked}"/>
|
||||
<Button Content="Speichern & weiter" Command="{Binding SaveAndNextCommand}"
|
||||
IsEnabled="{Binding !IsLocked}"/>
|
||||
<Button Content="{Binding LockLabel}" Command="{Binding ToggleLockCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="4" IsVisible="{Binding CanEnterExamGrades}">
|
||||
<TextBlock FontSize="11" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Prüfungen Klasse 10 — nur eintragen, was in diesem Fach abgelegt wurde. Die Prüfungsleistung geht mit einem Drittel in die Endnote ein."/>
|
||||
<Grid ColumnDefinitions="*,*,*,*" ColumnSpacing="6">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBox Text="{Binding ProjectExamGrade}" PlaceholderText="Projektprüfung"
|
||||
IsEnabled="{Binding !IsLocked}" ToolTip.Tip="Zählt wie ein weiteres Halbjahr."/>
|
||||
<TextBlock Text="{Binding ProjectExamGradeError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding ProjectExamGradeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<TextBox Text="{Binding WrittenExamGrade}" PlaceholderText="Schriftliche Prüfung"
|
||||
IsEnabled="{Binding !IsLocked}" ToolTip.Tip="Schriftliche Abschlussprüfung (Deutsch, Mathematik, erste Fremdsprache)."/>
|
||||
<TextBlock Text="{Binding WrittenExamGradeError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding WrittenExamGradeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBox Text="{Binding OralExamGrade}" PlaceholderText="Mündliche Prüfung"
|
||||
IsEnabled="{Binding !IsLocked}" ToolTip.Tip="Mündliche Abschlussprüfung im gewählten Fach."/>
|
||||
<TextBlock Text="{Binding OralExamGradeError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding OralExamGradeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" Spacing="2">
|
||||
<TextBox Text="{Binding AdditionalOralExamGrade}" PlaceholderText="Zusätzl. mündliche Prüfung"
|
||||
IsEnabled="{Binding !IsLocked}" ToolTip.Tip="Nachprüfung: im schriftlichen Prüfungsfach 1:1 mit der schriftlichen Prüfung verrechnet (ohne Rundung), sonst selbst ein Drittel der Endnote."/>
|
||||
<TextBlock Text="{Binding AdditionalOralExamGradeError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding AdditionalOralExamGradeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding OverrideReasonError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding OverrideReasonError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" Foreground="Green" FontSize="11"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Mitarbeit -->
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="Mitarbeit" FontSize="13" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<RadioButton Content="Abschnitte" GroupName="ChartMode" IsChecked="{Binding IsSectionMode}" FontSize="12"/>
|
||||
<RadioButton Content="Verlauf" GroupName="ChartMode" IsChecked="{Binding IsContinuousMode, Mode=OneWay}"
|
||||
FontSize="12" Margin="0,0,10,0"/>
|
||||
<TextBlock Text="Zoom" FontSize="10" Opacity="0.6" VerticalAlignment="Center"/>
|
||||
<Slider Minimum="0.65" Maximum="2.25" Value="{Binding TimelineZoom}" Width="120" TickFrequency="0.1"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*" ColumnSpacing="8" IsVisible="{Binding IsSectionMode}">
|
||||
<Button Grid.Column="0" Content="◂" Command="{Binding PreviousSectionCommand}" Padding="8,3"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding SectionPosition}" VerticalAlignment="Center"
|
||||
Opacity="0.6" FontSize="12"/>
|
||||
<Button Grid.Column="2" Content="▸" Command="{Binding NextSectionCommand}" Padding="8,3"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding SectionTitle}" VerticalAlignment="Center" FontSize="12"/>
|
||||
</Grid>
|
||||
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="1" CornerRadius="7" ClipToBounds="True">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<views:ParticipationTimelineChart Timeline="{Binding ChartGroups}" Zoom="{Binding TimelineZoom}"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding ParticipationGrades}" IsVisible="{Binding HasParticipationGrades}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AssistantGradeRow">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,5" Margin="0,0,8,6" Opacity="{Binding DisplayOpacity}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontSize="12"/>
|
||||
<TextBlock Text="{Binding DateDisplay}" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Value}" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Arbeiten -->
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Arbeiten" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Keine Arbeiten im gewählten Zeitraum." FontSize="12" Opacity="0.5"
|
||||
IsVisible="{Binding !HasWorks}"/>
|
||||
<ItemsControl ItemsSource="{Binding Works}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AssistantWorkCard">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="12,8" Margin="0,0,8,8" Width="210"
|
||||
Opacity="{Binding DisplayOpacity}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="1">
|
||||
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Title}"/>
|
||||
<TextBlock Text="{Binding KindLabel}" FontSize="10" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding DateDisplay}" FontSize="10" Opacity="0.65"/>
|
||||
<TextBlock Text="{Binding ClassAverageDisplay}" FontSize="10" Opacity="0.65"
|
||||
IsVisible="{Binding ClassAverageDisplay, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding ContextLabel}" FontSize="10" FontStyle="Italic"
|
||||
IsVisible="{Binding IsContext}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding GradeDisplay}" FontSize="24" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Right"/>
|
||||
<TextBlock Text="{Binding PercentDisplay}" FontSize="10" Opacity="0.65"
|
||||
HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Sonstige Noten -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasOtherGrades}">
|
||||
<TextBlock Text="Sonstige Noten" FontSize="13" FontWeight="SemiBold"/>
|
||||
<ItemsControl ItemsSource="{Binding OtherGrades}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AssistantGradeRow">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,5" Margin="0,0,8,6" Opacity="{Binding DisplayOpacity}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontSize="12"/>
|
||||
<TextBlock Text="{Binding DateDisplay}" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Value}" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button Grid.Column="1" Grid.Row="1" Content="Schließen" HorizontalAlignment="Right"
|
||||
Margin="0,14,0,0" Click="OnClose"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class ReportGradeAssistantDialog : Window
|
||||
{
|
||||
public ReportGradeAssistantDialog() => InitializeComponent();
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -61,9 +61,11 @@
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,*,Auto" Margin="0,16,0,0">
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,16,0,0">
|
||||
<Button Grid.Column="0" Content="Als CSV exportieren" Click="OnExportClick"/>
|
||||
<Button Grid.Column="2" Content="Schließen" Click="OnClose"/>
|
||||
<Button Grid.Column="1" Content="Assistent …" Click="OnOpenAssistantClick" Margin="8,0,0,0"
|
||||
ToolTip.Tip="Schüler einzeln durchgehen: Mitarbeitsverlauf, Arbeiten und Noten auf einer Seite"/>
|
||||
<Button Grid.Column="3" Content="Schließen" Click="OnClose"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -10,6 +10,18 @@ public partial class ReportGradeDialog : Window
|
||||
{
|
||||
public ReportGradeDialog() => InitializeComponent();
|
||||
|
||||
/// Öffnet den Zeugnisnoten-Assistenten über diesem Dialog; vom Aufrufer gesetzt, der die
|
||||
/// Gruppendaten kennt.
|
||||
public Func<Window, Task>? OnOpenAssistant { get; set; }
|
||||
|
||||
private async void OnOpenAssistantClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (OnOpenAssistant is null) return;
|
||||
await OnOpenAssistant(this);
|
||||
// Der Assistent schreibt dieselben Zeugnisnoten — Liste danach neu aufbauen.
|
||||
(DataContext as ReportGradeDialogViewModel)?.Refresh();
|
||||
}
|
||||
|
||||
private async void OnExportClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not ReportGradeDialogViewModel vm) return;
|
||||
|
||||
@@ -290,6 +290,24 @@ public class GradingServiceTests
|
||||
|
||||
// ── CalculateReportGrade ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CalculateReportGradeValue_LiefertUngerundetenGewichtetenWert()
|
||||
{
|
||||
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
|
||||
var value = _sut.CalculateReportGradeValue([("2", 1.0)], [("3", 1.0)], [("1", 1.0)], scheme);
|
||||
|
||||
Assert.Equal(2.3, value!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateReportGradeValue_OhneWerte_Null()
|
||||
{
|
||||
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
|
||||
Assert.Null(_sut.CalculateReportGradeValue([], [], [], scheme));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateReportGrade_AlleBereicheVorhanden_GewichtetGemaessSchema()
|
||||
{
|
||||
|
||||
@@ -309,6 +309,72 @@ eigenes Werkzeug, nur im Kopf überschlagen.
|
||||
damit auch die "Gesamt"-Spalte der Notenübersicht (2.1.2) und `CalculateReportGrade`, die
|
||||
beide auf `WeightedAverage` aufbauen.
|
||||
|
||||
### 2.7 Zeugnisnoten-Assistent (Nutzer-Feedback)
|
||||
|
||||
Selten gebraucht, dann aber wichtig: vor Zeugnissen, Sprechtagen und blauen Briefen die Leistung
|
||||
eines Schülers noch einmal im Zusammenhang sehen, statt nur eine berechnete Zahl in der Liste.
|
||||
"Letztendlich keine sturen Rechnungen, sondern pädagogische Entscheidungen." Bewusst nicht auf
|
||||
oberster Ebene — Einstieg über den Button "Assistent …" im Zeugnisnoten-Dialog (2.4), der als
|
||||
schnelle Listenansicht bestehen bleibt.
|
||||
|
||||
- [x] **2.7.1** Assistent mit Schülerliste (Status offen / gespeichert / festgeschrieben) und einer
|
||||
Seite je Schüler: berechnete Note mit ungerundetem Wert und Teilbereichen, Fehlquote,
|
||||
Übersteuern mit Pflichtbegründung, Speichern / "Speichern & weiter" / Festschreiben.
|
||||
[ReportGradeAssistantViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs),
|
||||
[ReportGradeAssistantDialog.axaml](LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml).
|
||||
Schreibt dieselben `ReportGrade`-Datensätze wie der Dialog (gleiche `Period`-Bezeichnungen),
|
||||
der Dialog baut seine Liste nach dem Schließen des Assistenten neu auf. Neue
|
||||
`GradingService.CalculateReportGradeValue()` liefert den ungerundeten Wert, damit sichtbar
|
||||
ist, wie knapp eine Note zwischen zwei Stufen liegt.
|
||||
- [x] **2.7.2** Mitarbeitsgraph in zwei Modi auf Basis des bestehenden
|
||||
`ParticipationTimelineChart`: "Abschnitte" (ein Abschnitt je Seite mit seiner Abschnittsnote,
|
||||
blätterbar; beim Schülerwechsel bleibt der Abschnitt stehen) und "Verlauf" (ganzer Zeitraum
|
||||
durchgehend scrollbar). Der laufende Abschnitt zeigt den Notenvorschlag als "vorläufig".
|
||||
Die Punkt-Erzeugung wurde dafür aus dem Mitarbeits-Assistenten in den gemeinsamen
|
||||
[ParticipationTimelineBuilder](LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs)
|
||||
herausgezogen — beide Assistenten zeigen denselben Graphen nach denselben Regeln.
|
||||
- [x] **2.7.3** Arbeiten als Karten (Note, Prozent, Kursschnitt, Art, Nachschreib-/Wiederholungs-
|
||||
Kennzeichen), dazu Mitarbeits- und sonstige Noten. **Halbjahre sind eigenständig:** Leistungen
|
||||
des 1. Halbjahrs zählen nicht in die Note des 2. Halbjahrs, werden dort aber ausgegraut als
|
||||
Kontext gezeigt (abschaltbar), weil sich Trends so besser erkennen lassen.
|
||||
- [x] **2.7.4** Endjahresnote: wird aus den Halbjahresnoten verrechnet (gleich gewichtet), nicht
|
||||
aus allen Einzelleistungen des Jahres. Eine gespeicherte Entscheidung (Übersteuerung bzw.
|
||||
festgeschriebener Wert) geht dabei der Live-Berechnung des Halbjahrs vor. Gilt auch für den
|
||||
Zeugnisnoten-Dialog (2.4), der "Gesamtes Schuljahr" zuvor aus allen Einzelleistungen
|
||||
gerechnet hat — beide Ansichten nutzen jetzt den gemeinsamen
|
||||
[ReportGradeCalculator](LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs).
|
||||
- [x] **2.7.4a** Sonderfall Klasse 10: die Note der Projektprüfung und/oder der mündlichen
|
||||
Prüfung zählt, wenn sie in diesem Fach abgelegt wurde, wie ein drittes bzw. viertes Halbjahr
|
||||
in die Endjahresnote ("klingt dämlich, aber so wird es berechnet"). Eingabe im Assistenten,
|
||||
nur bei `GradeLevel == 10` und Zeitraum "Gesamtes Schuljahr"; gespeichert als
|
||||
`ReportGrade.ProjectExamGrade`/`OralExamGrade` am Jahres-Datensatz (siehe
|
||||
`docs/Datenmodell.md`). Der Dialog rechnet gespeicherte Prüfungsnoten mit, bietet aber
|
||||
bewusst keine eigene Eingabe dafür.
|
||||
- [x] **2.7.4b** Nachprüfungen Klasse 10, gegen die Verordnung über die Sekundarstufe I der
|
||||
Oberschule (Stand 06/2025) und die PrüfV Sek. I geprüft. Zwei weitere Felder am
|
||||
Jahres-Datensatz: `WrittenExamGrade` (schriftliche Abschlussprüfung) und
|
||||
`AdditionalOralExamGrade` (zusätzliche mündliche Prüfung). Die Prüfungsleistung zählt ein
|
||||
Drittel, die unterrichtliche Leistung zwei Drittel (§ 14 Abs. 3 ObSchVO) — auch wenn nur
|
||||
eine Halbjahresnote vorliegt. Zusätzliche mündliche Prüfung im schriftlichen Prüfungsfach:
|
||||
1:1 mit der schriftlichen Prüfung (§ 13 Abs. 2 PrüfV); ohne Abschlussprüfung im Fach: 2:1
|
||||
(§ 13 Abs. 3 PrüfV). Widersprüchliche Eingaben (schriftliche *und* mündliche
|
||||
Abschlussprüfung; Nachprüfung im mündlichen Prüfungsfach) werden abgelehnt.
|
||||
**Entschieden (Nutzer):** (1) Die Projektprüfung bleibt ein eigenes Halbjahr (Schulpraxis),
|
||||
auch wenn § 4a Abs. 3 sie in die Halbjahresnote einfließen lässt. (2) Die Rundungsvorgaben
|
||||
der Verordnungen werden nicht nachgebildet — "es sind Leistungsbewertungen, mit denen nicht
|
||||
gerechnet werden kann", die Entscheidung ist pädagogisch: eine Prüfungsleistung zwischen
|
||||
zwei Noten geht ungerundet weiter, die Rundungsauswahl bleibt frei, und der Assistent zeigt
|
||||
einen Hinweis, wenn der Wert genau zwischen zwei Noten liegt. (3) Maßgeblich ist der
|
||||
neueste Stand der Verordnung (06/2025), ohne Übergangsregelung für ältere Jahrgänge.
|
||||
- [ ] **2.7.5** Vorläufige Noten: Stichtag "Stand bis …" (rechnet nur, speichert nichts; der
|
||||
laufende Mitarbeits-Abschnitt zählt mit seinem Vorschlag mit) und gespeicherte Notenstände
|
||||
(Datum, Anlass wie Sprechtag / Schülerrückfrage / blauer Brief, vorläufige schriftliche,
|
||||
mündliche und Gesamtnote, Notiz) — mehrere je Halbjahr, getrennt von der endgültigen
|
||||
`ReportGrade`.
|
||||
- [ ] **2.7.6** Noch im Detail zu besprechen: Grenzfall-Filter, Trendhinweise, Begründungsvorschlag
|
||||
beim Übersteuern, Kompetenzblock (Stärken / Baustellen), Gesprächsblatt als PDF,
|
||||
Schülermodus.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mündliche Mitarbeit — offene Punkte
|
||||
|
||||
@@ -89,6 +89,31 @@ zu `MembershipPeriod`). `CalculatedValue` ist das zuletzt berechnete Ergebnis, `
|
||||
überschreibt es bei pädagogischem Ermessen und erfordert `OverrideReason`. Nach dem Festschreiben
|
||||
(`IsLocked`) wird der Datensatz bei einer Neuberechnung nicht mehr verändert.
|
||||
|
||||
Die beiden Halbjahre werden jeweils aus den Einzelleistungen ihres Zeitraums berechnet und sind
|
||||
voneinander unabhängig. "Gesamtes Schuljahr" wird dagegen aus den Halbjahresnoten verrechnet
|
||||
(gleich gewichtet), nicht aus allen Einzelleistungen des Jahres.
|
||||
|
||||
Vier Felder sind nur am Jahres-Datensatz einer 10. Klasse belegt und halten Prüfungsnoten fest,
|
||||
soweit die Prüfung in diesem Fach abgelegt wurde:
|
||||
|
||||
- `WrittenExamGrade` / `OralExamGrade`: schriftliche bzw. mündliche Abschlussprüfung. Ein Fach
|
||||
hat höchstens eine von beiden. Die Prüfungsleistung geht mit einem Drittel in die Endnote ein,
|
||||
die unterrichtliche Leistung (beide Halbjahre) mit zwei Dritteln — § 14 Abs. 3 der Verordnung
|
||||
über die Sekundarstufe I der Oberschule.
|
||||
- `AdditionalOralExamGrade`: zusätzliche mündliche Prüfung ("Nachprüfung"). Im schriftlichen
|
||||
Prüfungsfach wird sie 1:1 mit der schriftlichen Prüfung zur Prüfungsleistung verrechnet
|
||||
(§ 13 Abs. 2 PrüfV Sek. I). In einem Fach ohne Abschlussprüfung ist sie selbst die
|
||||
Prüfungsleistung, also 2:1 (§ 13 Abs. 3 PrüfV Sek. I).
|
||||
- `ProjectExamGrade`: Projektprüfung, zählt wie ein weiteres Halbjahr. Das ist die Praxis der
|
||||
Schule; der Wortlaut der Verordnung (§ 4a Abs. 3: fließt in die Halbjahresnote des
|
||||
zugeordneten Fachs ein) wird bewusst nicht nachgebildet.
|
||||
|
||||
Die Rundungsvorgaben der Verordnungen sind bewusst nicht umgesetzt: Noten sind
|
||||
Leistungsbewertungen, die Entscheidung zwischen zwei Noten ist pädagogisch. Eine Prüfungsleistung
|
||||
zwischen zwei Noten geht ungerundet in die Rechnung ein; für die Endnote gilt die von der
|
||||
Lehrkraft gewählte Rundung, und der Assistent weist darauf hin, wenn der Wert genau zwischen
|
||||
zwei Noten liegt. Stand der Verordnungen: Oberschulverordnung in der Fassung von Juni 2025.
|
||||
|
||||
## Bewusst gespeicherte Momentaufnahmen
|
||||
|
||||
Einige berechnete Werte bleiben absichtlich gespeichert:
|
||||
|
||||
Reference in new issue
Block a user