Zwischenstand: vorläufige Noten zu Stichtag und Anlass
CI / build-and-test (push) Canceled after 0s

Ein Zwischenstand ist ein Durchgang aus Stichtag, Anlass und
Schülerauswahl (ganze Gruppe, Teilgruppe oder Einzelfall, mit
Schnellfilter für gefährdete Schüler). Der Zeugnisnoten-Assistent geht
die gewählten Schüler im Zwischenstand-Modus durch und notiert
vorläufige schriftliche, mündliche und Gesamtnote samt Notiz.

Gespeichert wird das als eigenes Modell InterimGrade, getrennt von der
Zeugnisnote. Die Berechnung endet am Stichtag und rechnet den laufenden
Mitarbeits-Abschnitt mit ein. Zwischenstände erscheinen im Assistenten
als Liste und Marker im Verlauf sowie als graue Spalte in der
Notenübersicht (nicht in "Gesamt", nicht im PDF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 13:01:34 +02:00
1 parent 67c5a906d1
commit d9eac0d1d2
24 files changed
+1038 -37

No files matched your search

@@ -94,6 +94,13 @@ public interface IReportGradeRepository
void Save(ReportGrade grade); void Save(ReportGrade grade);
void Delete(Guid id); void Delete(Guid id);
} }
public interface IInterimGradeRepository
{
List<InterimGrade> GetByGroup(Guid groupId);
List<InterimGrade> GetByStudentAndGroup(Guid studentId, Guid groupId);
void Save(InterimGrade grade);
void Delete(Guid id);
}
public interface IUnitRepository public interface IUnitRepository
{ {
Unit? GetById(Guid id); Unit? GetById(Guid id);
+23
View File
@@ -274,6 +274,29 @@ public class ReportGrade
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>
/// Zwischenstand: eine vorläufige Note zu einem Stichtag, wie sie zu einem Anlass (Sprechtag,
/// blauer Brief, Konferenz, Elternanfrage) genannt wurde. Bewusst getrennt von
/// <see cref="ReportGrade"/> — mehrere je Halbjahr möglich, ohne Einfluss auf die Zeugnisnote.
/// Zwischenstände mit gleichem <see cref="Date"/> und <see cref="Occasion"/> bilden einen
/// Durchgang durch (einen Teil der) Lerngruppe.
/// </summary>
public class InterimGrade
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
public Guid GroupId { get; set; }
/// <summary>Stichtag: Leistungen bis einschließlich dieses Tages lagen der Note zugrunde.</summary>
public DateOnly Date { get; set; }
public string Occasion { get; set; } = "";
public string? WrittenValue { get; set; }
public string? OralValue { get; set; }
public string OverallValue { get; set; } = "";
public string? Note { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Live timing is separate from the original lesson plan.</summary> /// <summary>Live timing is separate from the original lesson plan.</summary>
public class TeachingTimelineState public class TeachingTimelineState
{ {
@@ -61,6 +61,15 @@ public sealed class ChangeHookMatrixTests
return (grade.Id, () => repo.Delete(grade.Id)); return (grade.Id, () => repo.Delete(grade.Id));
}); });
yield return Case("InterimGrade", nameof(InterimGrade), db =>
{
var repo = new InterimGradeRepository(db);
var grade = new InterimGrade { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(),
Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag", OverallValue = "3" };
repo.Save(grade);
return (grade.Id, () => repo.Delete(grade.Id));
});
yield return Case("Unit", nameof(Unit), db => yield return Case("Unit", nameof(Unit), db =>
{ {
var repo = new UnitRepository(db); var repo = new UnitRepository(db);
+6
View File
@@ -48,6 +48,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades"); public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
public ILiteCollection<GradingScheme> GradingSchemes => _db.GetCollection<GradingScheme>("grading_schemes"); public ILiteCollection<GradingScheme> GradingSchemes => _db.GetCollection<GradingScheme>("grading_schemes");
public ILiteCollection<ReportGrade> ReportGrades => _db.GetCollection<ReportGrade>("report_grades"); public ILiteCollection<ReportGrade> ReportGrades => _db.GetCollection<ReportGrade>("report_grades");
public ILiteCollection<InterimGrade> InterimGrades => _db.GetCollection<InterimGrade>("interim_grades");
public ILiteCollection<GradingKeyTemplate> GradingKeyTemplates => _db.GetCollection<GradingKeyTemplate>("grading_key_templates"); public ILiteCollection<GradingKeyTemplate> GradingKeyTemplates => _db.GetCollection<GradingKeyTemplate>("grading_key_templates");
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units"); public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons"); public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
@@ -114,6 +115,9 @@ public class LiteDbContext : IDisposable
foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList()) foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList())
ReportGrades.Delete(reportGrade.Id); ReportGrades.Delete(reportGrade.Id);
foreach (var interimGrade in InterimGrades.Find(g => g.GroupId == id).ToList())
InterimGrades.Delete(interimGrade.Id);
foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList()) foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList())
GradingSchemes.Delete(scheme.Id); GradingSchemes.Delete(scheme.Id);
@@ -681,6 +685,8 @@ public class LiteDbContext : IDisposable
ReportGrades.EnsureIndex(x => x.StudentId); ReportGrades.EnsureIndex(x => x.StudentId);
ReportGrades.EnsureIndex("ux_student_group_period", ReportGrades.EnsureIndex("ux_student_group_period",
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true); BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true);
InterimGrades.EnsureIndex(x => x.GroupId);
InterimGrades.EnsureIndex(x => x.StudentId);
GradingKeyTemplates.EnsureIndex(x => x.GradingSystem); GradingKeyTemplates.EnsureIndex(x => x.GradingSystem);
Units.EnsureIndex(x => x.GroupId); Units.EnsureIndex(x => x.GroupId);
Lessons.EnsureIndex(x => x.UnitId); Lessons.EnsureIndex(x => x.UnitId);
+23 -1
View File
@@ -32,7 +32,8 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
db.Memberships.Count(m => m.StudentId == studentId), db.Memberships.Count(m => m.StudentId == studentId),
db.ExamResults.Count(r => r.StudentId == studentId), db.ExamResults.Count(r => r.StudentId == studentId),
db.Grades.Count(g => g.StudentId == studentId), db.Grades.Count(g => g.StudentId == studentId),
db.ReportGrades.Count(g => g.StudentId == studentId), // Zwischenstände sind vorläufige Zeugnisnoten und zählen hier mit.
db.ReportGrades.Count(g => g.StudentId == studentId) + db.InterimGrades.Count(g => g.StudentId == studentId),
db.ParticipationEntries.Count(e => e.StudentId == studentId), db.ParticipationEntries.Count(e => e.StudentId == studentId),
db.Documentation.FindAll().Count(d => d.Concerns(studentId)), db.Documentation.FindAll().Count(d => d.Concerns(studentId)),
db.StudentGroupPlans.FindAll().Count(plan => db.StudentGroupPlans.FindAll().Count(plan =>
@@ -330,6 +331,27 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor
} }
} }
public class InterimGradeRepository(LiteDbContext db) : IInterimGradeRepository
{
public List<InterimGrade> GetByGroup(Guid groupId) => db.InterimGrades.Find(g => g.GroupId == groupId).ToList();
public List<InterimGrade> GetByStudentAndGroup(Guid studentId, Guid groupId) =>
db.InterimGrades.Find(g => g.StudentId == studentId && g.GroupId == groupId).ToList();
public void Save(InterimGrade g)
{
ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId);
g.UpdatedAt = DateTime.UtcNow;
db.InterimGrades.Upsert(g);
db.OnChange?.Invoke(nameof(InterimGrade), g.Id.ToString(), "Save", g);
}
public void Delete(Guid id)
{
if (db.InterimGrades.FindById(id) is { } grade)
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.InterimGrades.Delete(id);
db.OnChange?.Invoke(nameof(InterimGrade), id.ToString(), "Delete", null);
}
}
public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
{ {
public List<ReportGrade> GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList(); public List<ReportGrade> GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList();
+11
View File
@@ -681,6 +681,17 @@ public class FakeTimeEntries : ITimeEntryRepository
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
} }
public class FakeInterimGrades : IInterimGradeRepository
{
private readonly List<InterimGrade> _all = [];
public List<InterimGrade> All => _all;
public List<InterimGrade> GetByGroup(Guid groupId) => _all.Where(g => g.GroupId == groupId).ToList();
public List<InterimGrade> GetByStudentAndGroup(Guid studentId, Guid groupId) =>
_all.Where(g => g.StudentId == studentId && g.GroupId == groupId).ToList();
public void Save(InterimGrade grade) { _all.RemoveAll(g => g.Id == grade.Id); _all.Add(grade); }
public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
}
public class FakeReportGrades : IReportGradeRepository public class FakeReportGrades : IReportGradeRepository
{ {
private readonly List<ReportGrade> _all = []; private readonly List<ReportGrade> _all = [];
@@ -22,7 +22,7 @@ public sealed class GroupDetailViewModelTests
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()), new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions()), students, memberships, groups, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new FakeInterimGrades(), new GradingService()),
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), new FakeSessions([])), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
@@ -23,16 +23,36 @@ public sealed class ReportGradeAssistantViewModelTests
public List<ParticipationSession> Sessions { get; } = []; public List<ParticipationSession> Sessions { get; } = [];
public FakeEntries Entries { get; } = new(); public FakeEntries Entries { get; } = new();
public List<ParticipationSection> Sections { get; } = []; public List<ParticipationSection> Sections { get; } = [];
public FakeInterimGrades InterimGrades { get; } = new();
public ReportGradeAssistantViewModel Build(DateOnly today, int gradeLevel = 8) public ReportGradeAssistantViewModel Build(DateOnly today, int gradeLevel = 8, InterimRunOptions? interim = null)
{ {
var schemes = new FakeSchemes(); var schemes = new FakeSchemes();
schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }); schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 });
return new ReportGradeAssistantViewModel( return new ReportGradeAssistantViewModel(
Grades, new FakeExams(Exams), Results, new FakeStudents([Anna, Ben]), new FakeMemberships([]), Grades, new FakeExams(Exams), Results, new FakeStudents([Anna, Ben]), new FakeMemberships([]),
schemes, ReportGrades, new FakeSessions(Sessions), Entries, new FakeAspects(), schemes, ReportGrades, new FakeSessions(Sessions), Entries, new FakeAspects(),
new FakeSections(Sections), new AttendanceBalanceService(), new GradingService(), new FakeSections(Sections), InterimGrades, new AttendanceBalanceService(), new GradingService(),
GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", gradeLevel, today); GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", gradeLevel, today, interim);
}
public InterimRunDialogViewModel BuildRunDialog(DateOnly today, Guid? preselected = null)
{
var schemes = new FakeSchemes();
schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 });
return new InterimRunDialogViewModel(
Grades, new FakeExams(Exams), Results, new FakeStudents([Anna, Ben]), new FakeMemberships([]),
schemes, ReportGrades, new FakeSessions(Sessions), Entries, new FakeAspects(),
new FakeSections(Sections), InterimGrades, new GradingService(),
GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", preselected, today);
}
public void AddSession(DateOnly date, int rating)
{
var session = new ParticipationSession { Id = Guid.NewGuid(), GroupId = GroupId, Date = date };
Sessions.Add(session);
Entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Anna.Id,
Ratings = [new AspectRating { Key = "quality", Value = rating }] });
} }
public Exam AddExam(string title, DateOnly date, string annaGrade) public Exam AddExam(string title, DateOnly date, string annaGrade)
@@ -385,4 +405,171 @@ public sealed class ReportGradeAssistantViewModelTests
Assert.Equal(3, vm.ChartGroups.Count); Assert.Equal(3, vm.ChartGroups.Count);
Assert.Equal(3, vm.ChartGroups.Sum(g => g.Points.Count)); Assert.Equal(3, vm.ChartGroups.Sum(g => g.Points.Count));
} }
// ── Zwischenstand (2.7.5) ────────────────────────────────────────────────
private static InterimRunOptions Sprechtag(params Guid[] students) =>
new(new DateOnly(2025, 11, 12), "Sprechtag", students);
[Fact]
public void Zwischenstand_RechnetNurBisZumStichtagUndMitLaufenderMitarbeit()
{
var f = new Fixture();
f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
f.AddExam("K2", new DateOnly(2025, 12, 1), "6"); // nach dem Stichtag
f.AddSession(new DateOnly(2025, 10, 20), -2); // laufender Abschnitt, schwache Mitarbeit
var vm = f.Build(InH1, interim: Sprechtag(Anna.Id));
Assert.True(vm.IsInterimMode);
Assert.Equal(Anna.Id, Assert.Single(vm.Students).StudentId);
Assert.Equal("K1", Assert.Single(vm.Works).Title);
Assert.Equal("2", vm.InterimWritten);
Assert.NotNull(vm.InterimOral);
Assert.Contains("laufender Abschnitt", vm.Tiles.Single(t => t.Label == "Mitarbeit").Hint);
// Die schwache laufende Mitarbeit zieht den Gesamtstand unter die reine Klausurnote.
Assert.True(int.Parse(vm.InterimOverall!) > 2);
Assert.Contains("12.11.2025", vm.CalculationDetail);
// Die Zeugnisnoten-Berechnung selbst bleibt ohne laufenden Abschnitt.
var report = f.Build(InH1);
Assert.Equal("–", report.Tiles.Single(t => t.Label == "Mitarbeit").Value);
}
[Fact]
public void Zwischenstand_NotierenSpeichertGetrenntVonDerZeugnisnote()
{
var f = new Fixture();
f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
var vm = f.Build(InH1, interim: Sprechtag(Anna.Id, Ben.Id));
vm.InterimOverall = "";
vm.NoteInterimCommand.Execute(null);
Assert.NotEmpty(vm.InterimOverallError);
Assert.Empty(f.InterimGrades.All);
vm.InterimOverall = "3-";
vm.InterimNote = "Mitarbeit muss besser werden";
vm.NoteInterimAndNextCommand.Execute(null);
var entry = Assert.Single(f.InterimGrades.All);
Assert.Equal(Anna.Id, entry.StudentId);
Assert.Equal(new DateOnly(2025, 11, 12), entry.Date);
Assert.Equal("Sprechtag", entry.Occasion);
Assert.Equal("3", entry.WrittenValue);
Assert.Equal("3-", entry.OverallValue);
Assert.Equal(0, f.ReportGrades.SavedCount);
Assert.Equal(AssistantGradeStatus.Noted, vm.Students[0].Status);
Assert.Equal("3-", vm.Students[0].GradeDisplay);
Assert.Equal(Ben.Id, vm.SelectedStudent!.StudentId);
// Erneutes Notieren im selben Durchgang überschreibt statt zu verdoppeln.
vm.SelectedStudent = vm.Students[0];
Assert.True(vm.HasInterimEntry);
Assert.Equal("3-", vm.InterimOverall);
vm.InterimOverall = "4";
vm.NoteInterimCommand.Execute(null);
Assert.Equal("4", Assert.Single(f.InterimGrades.All).OverallValue);
vm.RemoveInterimCommand.Execute(null);
Assert.Empty(f.InterimGrades.All);
Assert.Equal(AssistantGradeStatus.Open, vm.Students[0].Status);
}
[Fact]
public void Zwischenstaende_ErscheinenImZeugnisnotenModusAlsListeUndMarker()
{
var f = new Fixture();
f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
f.InterimGrades.Save(new InterimGrade { StudentId = Anna.Id, GroupId = GroupId,
Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag", WrittenValue = "3", OverallValue = "3" });
var vm = f.Build(new DateOnly(2025, 12, 10));
var row = Assert.Single(vm.InterimEntries);
Assert.Equal("12.11.2025 · Sprechtag", row.Title);
Assert.Equal("schriftlich 3", row.Detail);
Assert.Contains(vm.ChartGroups.SelectMany(g => g.Points), p => p.ExamLabel == "Zwischenstand: 3");
Assert.True(vm.IsReportMode);
}
[Fact]
public void Startdialog_VorauswahlGefaehrdeteUndBestaetigen()
{
var f = new Fixture();
f.AddExam("K1", new DateOnly(2025, 10, 1), "5");
f.Results.Add(new ExamResult { ExamId = f.Exams[0].Id, StudentId = Ben.Id, Grade = "2" });
var vm = f.BuildRunDialog(new DateOnly(2025, 11, 12));
Assert.All(vm.Students, s => Assert.True(s.IsSelected));
Assert.Equal(["5", "2"], vm.Students.Select(s => s.ProvisionalDisplay));
vm.SelectAtRiskCommand.Execute(null);
Assert.Equal([Anna.Id], vm.Students.Where(s => s.IsSelected).Select(s => s.StudentId));
vm.OccasionName = "Blauer Brief";
Assert.True(vm.Confirm());
Assert.Equal(new DateOnly(2025, 11, 12), vm.Result!.Date);
Assert.Equal("Blauer Brief", vm.Result.Occasion);
Assert.Equal([Anna.Id], vm.Result.StudentIds);
}
[Fact]
public void Startdialog_EinzelfallUndValidierung()
{
var f = new Fixture();
var vm = f.BuildRunDialog(new DateOnly(2025, 11, 12), preselected: Ben.Id);
Assert.Equal([Ben.Id], vm.Students.Where(s => s.IsSelected).Select(s => s.StudentId));
vm.OccasionName = "Elternanfrage";
vm.OccasionDetail = "Frau Muster";
Assert.Equal("Elternanfrage: Frau Muster", vm.Occasion);
vm.SelectNoneCommand.Execute(null);
Assert.False(vm.Confirm());
Assert.NotEmpty(vm.SelectionError);
vm.DateText = "12.11.2030";
Assert.False(vm.Confirm());
Assert.NotEmpty(vm.DateTextError);
}
[Fact]
public void Startdialog_BegonnenenDurchgangFortsetzen()
{
var f = new Fixture();
f.InterimGrades.Save(new InterimGrade { StudentId = Anna.Id, GroupId = GroupId,
Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag: 1. Elternsprechtag", OverallValue = "3" });
var vm = f.BuildRunDialog(new DateOnly(2025, 12, 1));
vm.SelectedExistingRun = Assert.Single(vm.ExistingRuns);
Assert.Equal("12.11.2025", vm.DateText);
Assert.Equal("Sprechtag: 1. Elternsprechtag", vm.Occasion);
Assert.Equal("notiert: 3", vm.Students.Single(s => s.StudentId == Anna.Id).NotedDisplay);
Assert.Equal("", vm.Students.Single(s => s.StudentId == Ben.Id).NotedDisplay);
}
[Fact]
public void Notenuebersicht_ZeigtZwischenstaendeAlsEigeneSpalteOhneEinflussAufGesamt()
{
var f = new Fixture();
f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
f.InterimGrades.Save(new InterimGrade { StudentId = Anna.Id, GroupId = GroupId,
Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag", OverallValue = "5" });
var vm = new GradeOverviewTabViewModel(f.Grades, new FakeExams(f.Exams), f.Results,
new FakeStudents([Anna, Ben]), new FakeMemberships([]), f.InterimGrades, new GradingService());
vm.Initialize(GroupId, GradingSystem.Grades1To6, GroupType.Class, "Testkurs", "2025/26");
var column = vm.Columns.Last();
Assert.True(column.IsInterim);
Assert.Equal("Zwischenstand 12.11. Sprechtag", column.Header);
var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
Assert.Equal("5", anna.Cells.Last());
Assert.Equal("2.00", anna.TotalDisplay);
Assert.Equal("", vm.Rows.Single(r => r.StudentId == Ben.Id).Cells.Last());
}
} }
+1
View File
@@ -164,6 +164,7 @@ public static class AppBootstrapper
services.AddSingleton<IGradeRepository, GradeRepository>(); services.AddSingleton<IGradeRepository, GradeRepository>();
services.AddSingleton<IGradingSchemeRepository, GradingSchemeRepository>(); services.AddSingleton<IGradingSchemeRepository, GradingSchemeRepository>();
services.AddSingleton<IReportGradeRepository, ReportGradeRepository>(); services.AddSingleton<IReportGradeRepository, ReportGradeRepository>();
services.AddSingleton<IInterimGradeRepository, InterimGradeRepository>();
services.AddSingleton<IGradingKeyTemplateRepository, GradingKeyTemplateRepository>(); services.AddSingleton<IGradingKeyTemplateRepository, GradingKeyTemplateRepository>();
services.AddSingleton<IUnitRepository, UnitRepository>(); services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>(); services.AddSingleton<ILessonRepository, LessonRepository>();
@@ -54,12 +54,16 @@ public partial class GradeOverviewTabViewModel : ObservableObject
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; } public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
public Func<Task>? OnCollectiveGrade { get; set; } public Func<Task>? OnCollectiveGrade { get; set; }
public Func<Task>? OnReportGrades { get; set; } public Func<Task>? OnReportGrades { get; set; }
public Func<Guid?, Task>? OnInterimGrades { get; set; }
public Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { get; set; } public Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { get; set; }
private readonly IInterimGradeRepository _interimGrades;
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams, public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IExamResultRepository results, IStudentRepository students,
IGroupMembershipRepository memberships, GradingService grading) IGroupMembershipRepository memberships, IInterimGradeRepository interimGrades, GradingService grading)
{ {
_interimGrades = interimGrades;
_grades = grades; _exams = exams; _results = results; _grades = grades; _exams = exams; _results = results;
_students = students; _memberships = memberships; _grading = grading; _students = students; _memberships = memberships; _grading = grading;
_selectedPeriod = PeriodOptions[0]; _selectedPeriod = PeriodOptions[0];
@@ -131,6 +135,15 @@ public partial class GradeOverviewTabViewModel : ObservableObject
await OnReportGrades(); await OnReportGrades();
} }
/// Zwischenstand notieren: mit markiertem Schüler nur für diesen vorausgewählt, sonst für alle.
[RelayCommand]
private async Task InterimGrades()
{
if (OnInterimGrades is null) return;
await OnInterimGrades(SelectedRow?.StudentId);
Recompute();
}
private void Recompute() private void Recompute()
{ {
var period = SelectedPeriod.Period; var period = SelectedPeriod.Period;
@@ -170,6 +183,15 @@ public partial class GradeOverviewTabViewModel : ObservableObject
Columns.Add(new GradeOverviewColumnDef(header)); Columns.Add(new GradeOverviewColumnDef(header));
} }
// Zwischenstände (2.7.5): je Durchgang eine Spalte, grau dargestellt. Sie sind vorläufig
// und fließen bewusst nicht in "Gesamt" ein.
var interimGrades = _interimGrades.GetByGroup(_groupId)
.Where(g => g.Date >= periodFrom && g.Date <= periodTo)
.ToList();
var interimRuns = interimGrades.Select(g => (g.Date, g.Occasion)).Distinct().OrderBy(r => r.Date).ToList();
foreach (var run in interimRuns)
Columns.Add(new GradeOverviewColumnDef($"Zwischenstand {run.Date:dd.MM.} {run.Occasion}", isInterim: true));
Rows.Clear(); Rows.Clear();
foreach (var student in students) foreach (var student in students)
{ {
@@ -207,6 +229,10 @@ public partial class GradeOverviewTabViewModel : ObservableObject
numeric.Add((grade.Value, grade.Weight)); numeric.Add((grade.Value, grade.Weight));
} }
foreach (var run in interimRuns)
cells.Add(FormatValue(interimGrades.FirstOrDefault(g =>
g.StudentId == student.Id && g.Date == run.Date && g.Occasion == run.Occasion)?.OverallValue));
var total = numeric.Count == 0 ? (double?)null : _grading.WeightedAverage(numeric); var total = numeric.Count == 0 ? (double?)null : _grading.WeightedAverage(numeric);
var totalDisplay = total is null ? "–" : total.Value.ToString("0.00", CultureInfo.InvariantCulture); var totalDisplay = total is null ? "–" : total.Value.ToString("0.00", CultureInfo.InvariantCulture);
@@ -236,9 +262,11 @@ public partial class GradeOverviewTabViewModel : ObservableObject
} }
public class GradeOverviewColumnDef(string header) public class GradeOverviewColumnDef(string header, bool isInterim = false)
{ {
public string Header { get; } = header; public string Header { get; } = header;
/// Vorläufige Note eines Zwischenstand-Durchgangs — grau, nicht in "Gesamt", nicht im PDF.
public bool IsInterim { get; } = isInterim;
} }
public class GradeOverviewRow(Guid studentId, string name, List<string> cells, string totalDisplay, double? totalSortValue) public class GradeOverviewRow(Guid studentId, string name, List<string> cells, string totalDisplay, double? totalSortValue)
@@ -0,0 +1,36 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <param name="RunningParticipation">Notenvorschlag des am Stichtag laufenden
/// Mitarbeits-Abschnitts, der in <paramref name="Result"/> bereits eingerechnet ist.</param>
public sealed record InterimResult(ParticipationPeriod Period, ReportGradeResult Result, string? RunningParticipation);
/// <summary>
/// Vorläufige Halbjahresnote zu einem Stichtag (Zwischenstand, 2.7.5). Anders als die
/// Zeugnisnote zählt hier der laufende Mitarbeits-Abschnitt mit seinem Vorschlag mit — sonst
/// bestünde ein Zwischenstand früh im Halbjahr nur aus dem Schriftlichen.
/// </summary>
public sealed class InterimGradeCalculator(ReportGradeCalculator calculator, ParticipationTimelineBuilder timeline,
IReadOnlyList<ParticipationSection> sections)
{
/// Das Halbjahr, zu dem ein Stichtag gehört.
public ParticipationPeriod HalfOf(DateOnly date) =>
date <= calculator.Range(ParticipationPeriod.H1).To ? ParticipationPeriod.H1 : ParticipationPeriod.H2;
public InterimResult Calculate(Guid studentId, DateOnly asOf, RoundingRule rounding)
{
var period = HalfOf(asOf);
var from = calculator.Range(period).From;
// "Laufend" aus Sicht des Stichtags: alles nach dem letzten bis dahin beendeten Abschnitt.
var closed = sections.Where(s => s.EndDate <= asOf).ToList();
var openStart = timeline.OpenStart(closed, asOf);
if (openStart < from) openStart = from;
var running = openStart <= asOf ? timeline.ComputeSuggestion(studentId, openStart, asOf) : null;
var result = calculator.Calculate(studentId, period, rounding, asOf: asOf, runningParticipation: running);
return new InterimResult(period, result, running);
}
}
@@ -0,0 +1,211 @@
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;
// ── Zwischenstand starten (2.7.5) ────────────────────────────────────────────
//
// Legt einmal Stichtag, Anlass und Schülerauswahl fest; danach geht der Zeugnisnoten-Assistent
// im Zwischenstand-Modus nur die gewählten Schüler durch. Ganze Gruppe (Sprechtag, blaue
// Briefe), Teilgruppe (Konferenz) und Einzelfall (Elternanfrage, Abgang) sind derselbe Ablauf
// mit unterschiedlicher Auswahl.
public partial class InterimRunDialogViewModel : ObservableObject
{
private const string DateFormat = "dd.MM.yyyy";
private const string OtherOccasion = "Sonstiges";
private readonly IInterimGradeRepository _interimGrades;
private readonly InterimGradeCalculator _calculator;
private readonly ReportGradeCalculator _reportCalculator;
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
private readonly List<Student> _allStudents;
private readonly Guid _groupId;
private readonly GradingSystem _gradingSystem;
private readonly Guid? _preselectedStudentId;
public string GroupLabel { get; }
[ObservableProperty] private string _dateText;
[ObservableProperty] private string _dateTextError = "";
[ObservableProperty] private string _occasionName;
[ObservableProperty] private string _occasionDetail = "";
[ObservableProperty] private string _thresholdText;
[ObservableProperty] private string _thresholdTextError = "";
[ObservableProperty] private string _selectionError = "";
[ObservableProperty] private InterimRunChoice? _selectedExistingRun;
public string[] OccasionOptions { get; } =
["Sprechtag", "Blauer Brief", "Konferenz", "Elternanfrage", "Schülerrückfrage", "Abgang", OtherOccasion];
public ObservableCollection<InterimRunStudentRow> Students { get; } = [];
public List<InterimRunChoice> ExistingRuns { get; }
public bool HasExistingRuns => ExistingRuns.Count > 0;
public string AtRiskLabel => _gradingSystem == GradingSystem.Grades1To6
? "Nur gefährdete: Note ab" : "Nur gefährdete: Punkte bis";
/// Nach erfolgreichem <see cref="Confirm"/> gesetzt.
public InterimRunOptions? Result { get; private set; }
public InterimRunDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
IParticipationSessionRepository participationSessions, IParticipationRepository participation,
IParticipationAspectRepository aspects, IParticipationSectionRepository sections,
IInterimGradeRepository interimGrades, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear,
Guid? preselectedStudentId = null, DateOnly? today = null)
{
_interimGrades = interimGrades; _groupId = groupId; _gradingSystem = gradingSystem;
_preselectedStudentId = preselectedStudentId;
GroupLabel = groupLabel;
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
_allStudents = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
var timeline = new ParticipationTimelineBuilder(participationSessions, participation, aspects, exams, results,
grades, _membershipsByStudent, grading, groupId, gradingSystem);
_reportCalculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
_membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear);
_calculator = new InterimGradeCalculator(_reportCalculator, timeline,
sections.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList());
ExistingRuns = interimGrades.GetByGroup(groupId)
.GroupBy(g => (g.Date, g.Occasion))
.OrderByDescending(g => g.Key.Date)
.Select(g => new InterimRunChoice(g.Key.Date, g.Key.Occasion, g.Count()))
.ToList();
_occasionName = OccasionOptions[0];
_thresholdText = gradingSystem == GradingSystem.Grades1To6 ? "5" : "4";
_dateText = (today ?? DateOnly.FromDateTime(DateTime.Today)).ToString(DateFormat);
RebuildStudents();
}
public string Occasion
{
get
{
var detail = OccasionDetail.Trim();
if (detail.Length == 0) return OccasionName;
return OccasionName == OtherOccasion ? detail : $"{OccasionName}: {detail}";
}
}
partial void OnDateTextChanged(string value) => RebuildStudents();
partial void OnOccasionNameChanged(string value) => RefreshNotedMarkers();
partial void OnOccasionDetailChanged(string value) => RefreshNotedMarkers();
partial void OnSelectedExistingRunChanged(InterimRunChoice? value)
{
if (value is null) return;
// Anlass zurück in Auswahl + Ergänzung zerlegen, damit derselbe Durchgang fortgesetzt wird.
var option = OccasionOptions.FirstOrDefault(o => o != OtherOccasion
&& (value.Occasion == o || value.Occasion.StartsWith(o + ": ", StringComparison.Ordinal)));
OccasionName = option ?? OtherOccasion;
OccasionDetail = option is null ? value.Occasion
: value.Occasion.Length > option.Length ? value.Occasion[(option.Length + 2)..] : "";
DateText = value.Date.ToString(DateFormat);
}
private bool TryParseDate(out DateOnly date)
{
DateTextError = "";
if (!DateOnly.TryParseExact(DateText.Trim(), DateFormat, null, DateTimeStyles.None, out date))
{
DateTextError = "Format TT.MM.JJJJ.";
return false;
}
var (from, to) = _reportCalculator.Range(ParticipationPeriod.FullYear);
if (date < from || date > to)
{
DateTextError = "Stichtag liegt außerhalb des Schuljahrs.";
return false;
}
return true;
}
private void RebuildStudents()
{
// Auswahl über den Neuaufbau retten; beim ersten Aufbau gilt die Vorauswahl.
var previous = Students.ToDictionary(s => s.StudentId, s => s.IsSelected);
Students.Clear();
if (!TryParseDate(out var date)) return;
foreach (var student in _allStudents)
{
if (_membershipsByStudent.TryGetValue(student.Id, out var membership)
&& !GroupMembershipService.IsActiveOn(membership, date)) continue;
var result = _calculator.Calculate(student.Id, date, RoundingRule.Commercial).Result;
var selected = previous.TryGetValue(student.Id, out var was)
? was
: _preselectedStudentId is null || _preselectedStudentId == student.Id;
Students.Add(new InterimRunStudentRow(student.Id, student.FullName, result.Rounded) { IsSelected = selected });
}
RefreshNotedMarkers();
}
private void RefreshNotedMarkers()
{
if (!DateOnly.TryParseExact(DateText.Trim(), DateFormat, null, DateTimeStyles.None, out var date)) return;
var noted = _interimGrades.GetByGroup(_groupId)
.Where(g => g.Date == date && g.Occasion == Occasion)
.GroupBy(g => g.StudentId).ToDictionary(g => g.Key, g => g.First().OverallValue);
foreach (var row in Students)
row.NotedDisplay = noted.TryGetValue(row.StudentId, out var value) ? $"notiert: {value}" : "";
}
[RelayCommand]
private void SelectAll() { foreach (var row in Students) row.IsSelected = true; }
[RelayCommand]
private void SelectNone() { foreach (var row in Students) row.IsSelected = false; }
/// Wählt genau die Schüler, deren vorläufige Note die Schwelle erreicht — für blaue Briefe.
[RelayCommand]
private void SelectAtRisk()
{
ThresholdTextError = "";
if (!int.TryParse(ThresholdText.Trim(), out var threshold))
{
ThresholdTextError = "Ganze Zahl erforderlich.";
return;
}
foreach (var row in Students)
row.IsSelected = int.TryParse(row.ProvisionalGrade, out var grade)
&& (_gradingSystem == GradingSystem.Grades1To6 ? grade >= threshold : grade <= threshold);
}
public bool Confirm()
{
SelectionError = "";
var valid = TryParseDate(out var date);
var selected = Students.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
if (valid && selected.Count == 0) { SelectionError = "Mindestens einen Schüler auswählen."; valid = false; }
if (!valid) return false;
Result = new InterimRunOptions(date, Occasion, selected);
return true;
}
}
public class InterimRunChoice(DateOnly date, string occasion, int count)
{
public DateOnly Date { get; } = date;
public string Occasion { get; } = occasion;
public override string ToString() => $"{Date:dd.MM.yyyy} · {Occasion} ({count} notiert)";
}
public partial class InterimRunStudentRow(Guid studentId, string name, string? provisionalGrade) : ObservableObject
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
public string? ProvisionalGrade { get; } = provisionalGrade;
public string ProvisionalDisplay => ProvisionalGrade ?? "–";
[ObservableProperty] private bool _isSelected;
[ObservableProperty] private string _notedDisplay = "";
}
@@ -0,0 +1,165 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// Ein Zwischenstand-Durchgang: Stichtag und Anlass gelten für alle gewählten Schüler.
public sealed record InterimRunOptions(DateOnly Date, string Occasion, IReadOnlyCollection<Guid> StudentIds);
// ── Zwischenstand-Modus des Assistenten (2.7.5) ──────────────────────────────
//
// Mit InterimRunOptions gestartet, geht der Assistent nur die gewählten Schüler durch und
// notiert je Schüler eine vorläufige schriftliche, mündliche und Gesamtnote zum Stichtag —
// als InterimGrade, ohne die Zeugnisnote (ReportGrade) anzufassen. Vorgeschlagen wird der
// Stand aus InterimGradeCalculator, also inklusive des laufenden Mitarbeits-Abschnitts.
public partial class ReportGradeAssistantViewModel
{
[ObservableProperty] private string? _interimWritten;
[ObservableProperty] private string? _interimOral;
[ObservableProperty] private string? _interimOverall;
[ObservableProperty] private string? _interimNote;
[ObservableProperty] private string _interimWrittenError = "";
[ObservableProperty] private string _interimOralError = "";
[ObservableProperty] private string _interimOverallError = "";
[ObservableProperty] private bool _hasInterimEntry;
/// Alle bisher notierten Zwischenstände des gewählten Schülers — in beiden Modi sichtbar.
public ObservableCollection<AssistantInterimRow> InterimEntries { get; } = [];
public bool HasInterimEntries => InterimEntries.Count > 0;
public bool IsInterimMode => _interim is not null;
public bool IsReportMode => _interim is null;
public string WindowTitle => IsInterimMode ? "Zwischenstand" : "Zeugnisnoten-Assistent";
public string InterimTitle => _interim is null
? "" : $"Zwischenstand {_interim.Date:dd.MM.yyyy} · {_interim.Occasion}";
/// Startet aus dem Zeugnisnoten-Modus einen Zwischenstand für den gezeigten Schüler; vom
/// Fenster gesetzt, das den Startdialog öffnen kann.
public Func<Guid, Task>? OnStartInterim { get; set; }
private InterimGrade? CurrentInterim(Guid studentId) => _interim is null ? null
: _interimGrades.GetByStudentAndGroup(studentId, _groupId)
.FirstOrDefault(g => g.Date == _interim.Date && g.Occasion == _interim.Occasion);
private void RefreshInterimItem(AssistantStudentItem item)
{
var existing = CurrentInterim(item.StudentId);
if (existing is not null)
item.Update(existing.OverallValue, AssistantGradeStatus.Noted);
else
item.Update(_interimCalculator.Calculate(item.StudentId, _interim!.Date, RoundingRule).Result.Rounded ?? "–",
AssistantGradeStatus.Open);
}
private void ShowInterim(Guid studentId)
{
var interim = _interimCalculator.Calculate(studentId, _interim!.Date, RoundingRule);
var result = interim.Result;
_calculatedValue = result.Rounded;
CalculatedDisplay = result.Rounded ?? "–";
var between = result.Value is { } value ? ReportGradeCalculator.BetweenGrades(value) : null;
BorderlineHint = between is null ? "" : $"Liegt genau {between} — pädagogische Entscheidung.";
CalculationDetail = result.Value is null
? "Noch keine Leistungen bis zum Stichtag."
: $"Stand {_interim.Date:dd.MM.yyyy} · gewichtet {Format(result.Value.Value)} vor Rundung";
var scheme = _calculator.Scheme;
Tiles.Clear();
Tiles.Add(BucketTile("Schriftlich", result.Written, scheme.ExamsPercent));
Tiles.Add(new AssistantTile("Mitarbeit",
result.Participation.HasValue ? Format(result.Participation.Value) : "–",
$"{scheme.ParticipationPercent:0.#} %" +
(interim.RunningParticipation is null ? "" : $" · laufender Abschnitt: {interim.RunningParticipation}")));
Tiles.Add(BucketTile("Sonstige", result.Other, scheme.OtherPercent));
// Bereits notiert: der notierte Stand gilt. Sonst der berechnete Vorschlag zum Anpassen.
var existing = CurrentInterim(studentId);
HasInterimEntry = existing is not null;
InterimWritten = existing is not null ? existing.WrittenValue : RoundOrNull(result.Written);
InterimOral = existing is not null ? existing.OralValue : RoundOrNull(result.Participation);
InterimOverall = existing is not null ? existing.OverallValue : result.Rounded;
InterimNote = existing?.Note;
}
private string? RoundOrNull(double? value) => value.HasValue ? _calculator.Round(value.Value, RoundingRule) : null;
private void LoadInterimEntries(Guid studentId)
{
InterimEntries.Clear();
foreach (var entry in _interimGrades.GetByStudentAndGroup(studentId, _groupId)
.OrderByDescending(g => g.Date).ThenByDescending(g => g.UpdatedAt))
InterimEntries.Add(new AssistantInterimRow(entry));
OnPropertyChanged(nameof(HasInterimEntries));
}
[RelayCommand]
private void NoteInterim() => TryNoteInterim();
[RelayCommand]
private void NoteInterimAndNext()
{
if (!TryNoteInterim()) return;
if (CanGoNextStudent) NextStudent();
}
private bool TryNoteInterim()
{
if (_interim is null || SelectedStudent is null) return false;
ClearErrors();
var valid = true;
if (!IsGradeOrEmpty(InterimWritten)) { InterimWrittenError = "Keine gültige Note."; valid = false; }
if (!IsGradeOrEmpty(InterimOral)) { InterimOralError = "Keine gültige Note."; valid = false; }
if (NullIfEmpty(InterimOverall) is null) { InterimOverallError = "Gesamtnote erforderlich."; valid = false; }
else if (!IsGradeOrEmpty(InterimOverall)) { InterimOverallError = "Keine gültige Note."; valid = false; }
if (!valid) return false;
var studentId = SelectedStudent.StudentId;
var entry = CurrentInterim(studentId) ?? new InterimGrade
{
StudentId = studentId, GroupId = _groupId, Date = _interim.Date, Occasion = _interim.Occasion,
};
entry.WrittenValue = NullIfEmpty(InterimWritten);
entry.OralValue = NullIfEmpty(InterimOral);
entry.OverallValue = NullIfEmpty(InterimOverall)!;
entry.Note = NullIfEmpty(InterimNote);
_interimGrades.Save(entry);
RefreshItem(SelectedStudent);
ShowStudent();
StatusMessage = "Zwischenstand notiert.";
return true;
}
[RelayCommand]
private void RemoveInterim()
{
if (SelectedStudent is null || CurrentInterim(SelectedStudent.StudentId) is not { } entry) return;
_interimGrades.Delete(entry.Id);
RefreshItem(SelectedStudent);
ShowStudent();
StatusMessage = "Zwischenstand entfernt.";
}
[RelayCommand]
private async Task StartInterim()
{
if (OnStartInterim is null || SelectedStudent is null) return;
await OnStartInterim(SelectedStudent.StudentId);
ShowStudent();
}
}
public class AssistantInterimRow(InterimGrade entry)
{
public string Title { get; } = $"{entry.Date:dd.MM.yyyy} · {entry.Occasion}";
public string OverallValue { get; } = entry.OverallValue;
public string Detail { get; } = string.Join(" · ", new[]
{
entry.WrittenValue is null ? null : $"schriftlich {entry.WrittenValue}",
entry.OralValue is null ? null : $"mündlich {entry.OralValue}",
}.Where(part => part is not null));
public string Note { get; } = entry.Note ?? "";
}
@@ -32,6 +32,9 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
private readonly GradingService _grading; private readonly GradingService _grading;
private readonly ParticipationTimelineBuilder _timeline; private readonly ParticipationTimelineBuilder _timeline;
private readonly ReportGradeCalculator _calculator; private readonly ReportGradeCalculator _calculator;
private readonly InterimGradeCalculator _interimCalculator;
private readonly IInterimGradeRepository _interimGrades;
private readonly InterimRunOptions? _interim;
private readonly Guid _groupId; private readonly Guid _groupId;
private readonly int _gradeLevel; private readonly int _gradeLevel;
private readonly DateOnly _today; private readonly DateOnly _today;
@@ -125,11 +128,13 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
IParticipationSessionRepository participationSessions, IParticipationRepository participation, IParticipationSessionRepository participationSessions, IParticipationRepository participation,
IParticipationAspectRepository aspects, IParticipationSectionRepository sections, IParticipationAspectRepository aspects, IParticipationSectionRepository sections,
IInterimGradeRepository interimGrades,
AttendanceBalanceService attendanceBalance, GradingService grading, AttendanceBalanceService attendanceBalance, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear, Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear,
int gradeLevel, DateOnly? today = null) int gradeLevel, DateOnly? today = null, InterimRunOptions? interim = null)
{ {
_reportGrades = reportGrades; _participation = participation; _reportGrades = reportGrades; _participation = participation;
_interimGrades = interimGrades; _interim = interim;
_attendanceBalance = attendanceBalance; _grading = grading; _attendanceBalance = attendanceBalance; _grading = grading;
_groupId = groupId; _gradeLevel = gradeLevel; _groupId = groupId; _gradeLevel = gradeLevel;
_today = today ?? DateOnly.FromDateTime(DateTime.Today); _today = today ?? DateOnly.FromDateTime(DateTime.Today);
@@ -143,9 +148,11 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
_calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades, _calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
_membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear); _membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear);
// Vorbelegung: das Halbjahr, in dem wir uns gerade befinden. _interimCalculator = new InterimGradeCalculator(_calculator, _timeline, _sections);
var (h1From, h1To) = _calculator.Range(ParticipationPeriod.H1);
_selectedPeriod = _today >= h1From && _today <= h1To ? PeriodOptions[0] : PeriodOptions[1]; // Vorbelegung: das Halbjahr, in dem wir uns gerade befinden — bzw. das des Stichtags.
var half = _interimCalculator.HalfOf(_interim?.Date ?? _today);
_selectedPeriod = PeriodOptions.First(p => p.Period == half);
Reload(); Reload();
} }
@@ -190,6 +197,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
{ {
OverrideReasonError = ""; ProjectExamGradeError = ""; OralExamGradeError = ""; OverrideReasonError = ""; ProjectExamGradeError = ""; OralExamGradeError = "";
WrittenExamGradeError = ""; AdditionalOralExamGradeError = ""; WrittenExamGradeError = ""; AdditionalOralExamGradeError = "";
InterimWrittenError = ""; InterimOralError = ""; InterimOverallError = "";
} }
// ── Laden ──────────────────────────────────────────────────────────────── // ── Laden ────────────────────────────────────────────────────────────────
@@ -206,6 +214,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
Students.Clear(); Students.Clear();
foreach (var student in _allStudents) foreach (var student in _allStudents)
{ {
if (_interim is not null && !_interim.StudentIds.Contains(student.Id)) continue;
if (_membershipsByStudent.TryGetValue(student.Id, out var membership) if (_membershipsByStudent.TryGetValue(student.Id, out var membership)
&& !GroupMembershipService.Overlaps(membership, from, to)) continue; && !GroupMembershipService.Overlaps(membership, from, to)) continue;
var item = new AssistantStudentItem(student.Id, student.FullName); var item = new AssistantStudentItem(student.Id, student.FullName);
@@ -223,6 +232,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
private void RefreshItem(AssistantStudentItem item) private void RefreshItem(AssistantStudentItem item)
{ {
if (_interim is not null) { RefreshInterimItem(item); return; }
var record = _reportGrades.GetByStudentGroupPeriod(item.StudentId, _groupId, SelectedPeriod.Label); var record = _reportGrades.GetByStudentGroupPeriod(item.StudentId, _groupId, SelectedPeriod.Label);
var calculated = record is { IsLocked: true } var calculated = record is { IsLocked: true }
? NullIfEmpty(record.CalculatedValue) ? NullIfEmpty(record.CalculatedValue)
@@ -251,6 +261,8 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
Tiles.Clear(); Tiles.Clear();
CalculatedDisplay = "–"; CalculationDetail = ""; BorderlineHint = ""; AbsenceRateDisplay = ""; CalculatedDisplay = "–"; CalculationDetail = ""; BorderlineHint = ""; AbsenceRateDisplay = "";
OverrideValue = null; OverrideReason = null; IsLocked = false; OverrideValue = null; OverrideReason = null; IsLocked = false;
InterimEntries.Clear();
OnPropertyChanged(nameof(HasInterimEntries));
_sectionViews = []; _sectionViews = [];
ApplyChartMode(); ApplyChartMode();
NotifyLists(); NotifyLists();
@@ -259,9 +271,12 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
var studentId = SelectedStudent.StudentId; var studentId = SelectedStudent.StudentId;
var (from, to) = _calculator.Range(SelectedPeriod.Period); var (from, to) = _calculator.Range(SelectedPeriod.Period);
// Zwischenstand: alles nach dem Stichtag bleibt außen vor, auch in der Anzeige.
if (_interim is not null && _interim.Date < to) to = _interim.Date;
_membershipsByStudent.TryGetValue(studentId, out var membership); _membershipsByStudent.TryGetValue(studentId, out var membership);
var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label); var record = _interim is null
? _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label) : null;
_showing = true; _showing = true;
IsLocked = record is { IsLocked: true }; IsLocked = record is { IsLocked: true };
OverrideValue = record?.OverrideValue; OverrideValue = record?.OverrideValue;
@@ -272,7 +287,8 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
AdditionalOralExamGrade = CanEnterExamGrades ? record?.AdditionalOralExamGrade : null; AdditionalOralExamGrade = CanEnterExamGrades ? record?.AdditionalOralExamGrade : null;
_showing = false; _showing = false;
ShowCalculation(); if (_interim is not null) ShowInterim(studentId); else ShowCalculation();
LoadInterimEntries(studentId);
ShowAbsenceRate(studentId, from, to); ShowAbsenceRate(studentId, from, to);
// Im 2. Halbjahr auf Wunsch das Vorhalbjahr als Kontext mitzeigen — ohne Einfluss auf die Note. // Im 2. Halbjahr auf Wunsch das Vorhalbjahr als Kontext mitzeigen — ohne Einfluss auf die Note.
@@ -411,6 +427,13 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
private void BuildSections(Guid studentId, DateOnly displayFrom, DateOnly to) private void BuildSections(Guid studentId, DateOnly displayFrom, DateOnly to)
{ {
var points = _timeline.BuildPoints(studentId).Where(p => p.Date >= displayFrom && p.Date <= to).ToList(); var points = _timeline.BuildPoints(studentId).Where(p => p.Date >= displayFrom && p.Date <= to).ToList();
// Notierte Zwischenstände erscheinen als Marker im Verlauf.
points.AddRange(_interimGrades.GetByStudentAndGroup(studentId, _groupId)
.Where(g => g.Date >= displayFrom && g.Date <= to)
.Select(g => new WizardTimelinePoint(g.Date, isExam: true, ratingLabel: "",
examLabel: $"Zwischenstand: {g.OverallValue}", hasNote: false,
tooltip: $"{g.Date:dd.MM.yyyy} · {g.Occasion} · Zwischenstand {g.OverallValue}")));
points = points.OrderBy(p => p.Date).ToList();
var sectionGrades = _calculator.Grades var sectionGrades = _calculator.Grades
.Where(g => g.StudentId == studentId && g.Category == GradeCategory.Participation && g.Note is not null) .Where(g => g.StudentId == studentId && g.Category == GradeCategory.Participation && g.Note is not null)
.ToList(); .ToList();
@@ -430,13 +453,16 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
// Der laufende Abschnitt endet erst mit dem nächsten Abschluss im Mitarbeits-Assistenten; // Der laufende Abschnitt endet erst mit dem nächsten Abschluss im Mitarbeits-Assistenten;
// bis dahin gibt es nur einen Vorschlag aus den bisherigen Sitzungen. // bis dahin gibt es nur einen Vorschlag aus den bisherigen Sitzungen.
var openStart = _timeline.OpenStart(_sections, _today); var asOf = _interim?.Date ?? _today;
// Ohne jeden abgeschlossenen Abschnitt gehört der ganze Zeitraum zum laufenden — sonst
// fielen Arbeiten und Zwischenstände vor der ersten Mitarbeit-Sitzung aus dem Verlauf.
var openStart = _sections.Count == 0 ? displayFrom : _timeline.OpenStart(_sections, asOf);
if (openStart <= to) if (openStart <= to)
{ {
var group = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true); var group = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true);
foreach (var point in points.Where(p => p.Date >= openStart)) foreach (var point in points.Where(p => p.Date >= openStart))
group.Points.Add(point); group.Points.Add(point);
var end = _today < to ? _today : to; var end = asOf < to ? asOf : to;
var suggestion = _timeline.ComputeSuggestion(studentId, openStart < displayFrom ? displayFrom : openStart, end); var suggestion = _timeline.ComputeSuggestion(studentId, openStart < displayFrom ? displayFrom : openStart, end);
views.Add(new SectionView(group, views.Add(new SectionView(group,
$"Laufender Abschnitt seit {openStart:dd.MM.} · " + $"Laufender Abschnitt seit {openStart:dd.MM.} · " +
@@ -595,7 +621,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject
// ── Bausteine ──────────────────────────────────────────────────────────────── // ── Bausteine ────────────────────────────────────────────────────────────────
public enum AssistantGradeStatus { Open, Saved, Locked } public enum AssistantGradeStatus { Open, Saved, Locked, Noted }
public partial class AssistantStudentItem(Guid studentId, string name) : ObservableObject public partial class AssistantStudentItem(Guid studentId, string name) : ObservableObject
{ {
@@ -609,13 +635,14 @@ public partial class AssistantStudentItem(Guid studentId, string name) : Observa
{ {
AssistantGradeStatus.Locked => "festgeschrieben", AssistantGradeStatus.Locked => "festgeschrieben",
AssistantGradeStatus.Saved => "gespeichert", AssistantGradeStatus.Saved => "gespeichert",
AssistantGradeStatus.Noted => "notiert",
_ => "offen", _ => "offen",
}; };
public string StatusColorHex => Status switch public string StatusColorHex => Status switch
{ {
AssistantGradeStatus.Locked => "#2E9D57", AssistantGradeStatus.Locked => "#2E9D57",
AssistantGradeStatus.Saved => "#2E86DE", AssistantGradeStatus.Saved or AssistantGradeStatus.Noted => "#2E86DE",
_ => "#8A8A8A", _ => "#8A8A8A",
}; };
@@ -109,10 +109,16 @@ public sealed class ReportGradeCalculator
return !exam.Niveau.HasValue || membership?.Niveau == exam.Niveau; return !exam.Niveau.HasValue || membership?.Niveau == exam.Niveau;
} }
public string Round(double value, RoundingRule rounding) => _grading.RoundToGrade(value, _gradingSystem, rounding);
/// <param name="examGrades">Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne /// <param name="examGrades">Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne
/// Angabe gelten die am Jahres-Datensatz gespeicherten.</param> /// Angabe gelten die am Jahres-Datensatz gespeicherten.</param>
/// <param name="asOf">Stichtag für ein Halbjahr: nur Leistungen bis einschließlich dieses
/// Tages zählen.</param>
/// <param name="runningParticipation">Vorläufige Mitarbeitsnote des laufenden, noch nicht
/// abgeschlossenen Abschnitts — zählt für ein Halbjahr wie eine weitere Abschnittsnote.</param>
public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding, public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding,
YearExamGrades? examGrades = null) YearExamGrades? examGrades = null, DateOnly? asOf = null, string? runningParticipation = null)
{ {
if (period == ParticipationPeriod.FullYear) if (period == ParticipationPeriod.FullYear)
{ {
@@ -125,6 +131,7 @@ public sealed class ReportGradeCalculator
} }
var (from, to) = Range(period); var (from, to) = Range(period);
if (asOf is { } cutoff && cutoff < to) to = cutoff;
_membershipsByStudent.TryGetValue(studentId, out var membership); _membershipsByStudent.TryGetValue(studentId, out var membership);
var examEntries = new List<(Exam Exam, string Grade)>(); var examEntries = new List<(Exam Exam, string Grade)>();
@@ -144,6 +151,7 @@ public sealed class ReportGradeCalculator
.ToList(); .ToList();
var participation = studentGrades.Where(g => g.Category == GradeCategory.Participation) var participation = studentGrades.Where(g => g.Category == GradeCategory.Participation)
.Select(g => (Grade: g.Value, g.Weight)).ToList(); .Select(g => (Grade: g.Value, g.Weight)).ToList();
if (!string.IsNullOrWhiteSpace(runningParticipation)) participation.Add((runningParticipation, 1.0));
var other = studentGrades.Where(g => g.Category != GradeCategory.Participation) var other = studentGrades.Where(g => g.Category != GradeCategory.Participation)
.Select(g => (Grade: g.Value, g.Weight)).ToList(); .Select(g => (Grade: g.Value, g.Weight)).ToList();
// Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige. // Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
@@ -181,7 +181,7 @@ public partial class ManageStudentDialogViewModel : ObservableObject
AddReference("Gruppenzuordnungen", References.Memberships); AddReference("Gruppenzuordnungen", References.Memberships);
AddReference("Klausurergebnisse", References.ExamResults); AddReference("Klausurergebnisse", References.ExamResults);
AddReference("Einzelnoten", References.Grades); AddReference("Einzelnoten", References.Grades);
AddReference("Zeugnisnoten", References.ReportGrades); AddReference("Zeugnisnoten und Zwischenstände", References.ReportGrades);
AddReference("Mitarbeitseinträge", References.ParticipationEntries); AddReference("Mitarbeitseinträge", References.ParticipationEntries);
AddReference("Dokumentationseinträge", References.DocumentationEntries); AddReference("Dokumentationseinträge", References.DocumentationEntries);
} }
@@ -24,6 +24,8 @@
ToolTip.Tip="Leistungsüberblick mit Zielnoten-Rechner für diesen Schüler"/> ToolTip.Tip="Leistungsüberblick mit Zielnoten-Rechner für diesen Schüler"/>
<Button Content="+ Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="+ Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/> <Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Zwischenstand …" Command="{Binding InterimGradesCommand}" IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Vorläufige Noten zu einem Stichtag notieren (Sprechtag, blauer Brief, Anfrage) — mit markiertem Schüler nur für diesen"/>
<Button Content="Als PDF" Click="OnExportPdfClick" Margin="12,0,0,0" <Button Content="Als PDF" Click="OnExportPdfClick" Margin="12,0,0,0"
IsEnabled="{Binding !!Rows.Count}"/> IsEnabled="{Binding !!Rows.Count}"/>
</StackPanel> </StackPanel>
@@ -25,6 +25,10 @@ public partial class GradeOverviewTabView : UserControl
vm.OnManageStudentGrades = ShowStudentGradesDialog; vm.OnManageStudentGrades = ShowStudentGradesDialog;
vm.OnCollectiveGrade = ShowCollectiveGradeDialog; vm.OnCollectiveGrade = ShowCollectiveGradeDialog;
vm.OnReportGrades = ShowReportGradesDialog; vm.OnReportGrades = ShowReportGradesDialog;
vm.OnInterimGrades = async studentId =>
{
if (TopLevel.GetTopLevel(this) is Window owner) await ShowInterimRun(owner, studentId);
};
vm.OnShowPerformanceOverview = ShowPerformanceOverviewDialog; vm.OnShowPerformanceOverview = ShowPerformanceOverviewDialog;
vm.PropertyChanged += (_, pe) => vm.PropertyChanged += (_, pe) =>
{ {
@@ -75,12 +79,12 @@ public partial class GradeOverviewTabView : UserControl
App.Services.GetRequiredService<GradingService>(), App.Services.GetRequiredService<GradingService>(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear); _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear);
var dialog = new ReportGradeDialog { DataContext = dialogVm, OnOpenAssistant = ShowReportGradeAssistant }; var dialog = new ReportGradeDialog { DataContext = dialogVm, OnOpenAssistant = owner => ShowReportGradeAssistant(owner) };
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner); if (owner is not null) await dialog.ShowDialog(owner);
} }
private async Task ShowReportGradeAssistant(Window owner) private async Task ShowReportGradeAssistant(Window owner, InterimRunOptions? interim = null)
{ {
var dialogVm = new ReportGradeAssistantViewModel( var dialogVm = new ReportGradeAssistantViewModel(
App.Services.GetRequiredService<IGradeRepository>(), App.Services.GetRequiredService<IGradeRepository>(),
@@ -94,12 +98,42 @@ public partial class GradeOverviewTabView : UserControl
App.Services.GetRequiredService<IParticipationRepository>(), App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<IParticipationAspectRepository>(), App.Services.GetRequiredService<IParticipationAspectRepository>(),
App.Services.GetRequiredService<IParticipationSectionRepository>(), App.Services.GetRequiredService<IParticipationSectionRepository>(),
App.Services.GetRequiredService<IInterimGradeRepository>(),
App.Services.GetRequiredService<AttendanceBalanceService>(), App.Services.GetRequiredService<AttendanceBalanceService>(),
App.Services.GetRequiredService<GradingService>(), App.Services.GetRequiredService<GradingService>(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear, _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear,
App.Services.GetRequiredService<IGroupRepository>().GetById(_vm.GroupId)?.GradeLevel ?? 0); App.Services.GetRequiredService<IGroupRepository>().GetById(_vm.GroupId)?.GradeLevel ?? 0,
interim: interim);
await new ReportGradeAssistantDialog { DataContext = dialogVm }.ShowDialog(owner); var dialog = new ReportGradeAssistantDialog { DataContext = dialogVm };
dialogVm.OnStartInterim = studentId => ShowInterimRun(dialog, studentId);
await dialog.ShowDialog(owner);
}
/// Zwischenstand: erst Stichtag, Anlass und Schüler festlegen, dann geht der Assistent genau
/// diese Schüler durch.
private async Task ShowInterimRun(Window owner, Guid? preselectedStudentId)
{
var dialogVm = new InterimRunDialogViewModel(
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<IInterimGradeRepository>(),
App.Services.GetRequiredService<GradingService>(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear,
preselectedStudentId);
var dialog = new InterimRunDialog { DataContext = dialogVm };
if (await dialog.ShowDialog<bool>(owner) && dialogVm.Result is { } run)
await ShowReportGradeAssistant(owner, run);
} }
private async Task ShowPerformanceOverviewDialog(GradeOverviewRow row) private async Task ShowPerformanceOverviewDialog(GradeOverviewRow row)
@@ -123,10 +157,13 @@ public partial class GradeOverviewTabView : UserControl
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || _vm is null) return; if (topLevel is null || _vm is null) return;
// Vorläufige Zwischenstände gehören nicht in die gedruckte Notenliste. Ihre Spalten
// stehen immer am Ende, die Zellen lassen sich also einfach abschneiden.
var printed = _vm.Columns.Where(c => !c.IsInterim).ToList();
var data = new GradeListPrintData( var data = new GradeListPrintData(
_vm.GroupLabel, _vm.SchoolYear, _vm.SelectedPeriod.Label, _vm.GroupLabel, _vm.SchoolYear, _vm.SelectedPeriod.Label,
_vm.Columns.Select(c => c.Header).ToList(), printed.Select(c => c.Header).ToList(),
_vm.Rows.Select(r => new GradeListPrintRow(r.Name, r.Cells, r.TotalDisplay)).ToList()); _vm.Rows.Select(r => new GradeListPrintRow(r.Name, r.Cells.Take(printed.Count).ToList(), r.TotalDisplay)).ToList());
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildGradeListPdf(data); var pdf = App.Services.GetRequiredService<PdfExportService>().BuildGradeListPdf(data);
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider,
@@ -150,12 +187,15 @@ public partial class GradeOverviewTabView : UserControl
foreach (var (col, i) in _vm.Columns.Select((c, i) => (c, i))) foreach (var (col, i) in _vm.Columns.Select((c, i) => (c, i)))
{ {
var idx = i; var idx = i;
grid.Columns.Add(new DataGridTextColumn var column = new DataGridTextColumn
{ {
Header = col.Header, Header = col.Header,
Binding = new Binding($"Cells[{idx}]"), Binding = new Binding($"Cells[{idx}]"),
Width = new DataGridLength(120, DataGridLengthUnitType.Pixel), Width = new DataGridLength(120, DataGridLengthUnitType.Pixel),
}); };
// Zwischenstände sind vorläufig — grau, damit sie nicht wie eingetragene Noten wirken.
if (col.IsInterim) column.Foreground = Avalonia.Media.Brushes.Gray;
grid.Columns.Add(column);
} }
grid.Columns.Add(new DataGridTextColumn grid.Columns.Add(new DataGridTextColumn
@@ -0,0 +1,87 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.InterimRunDialog"
x:DataType="vm:InterimRunDialogViewModel"
Title="Zwischenstand"
Width="560" Height="720" MinWidth="480" MinHeight="520"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding GroupLabel}" FontSize="13" Opacity="0.5"/>
<TextBlock Text="Zwischenstand notieren" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Vorläufige Noten zu einem Stichtag — für Sprechtage, blaue Briefe, Konferenzen oder einzelne Anfragen. Die Zeugnisnoten bleiben unberührt."/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="8" Margin="0,14,0,0">
<StackPanel Spacing="2" IsVisible="{Binding HasExistingRuns}">
<TextBlock Text="Begonnenen Durchgang fortsetzen" FontSize="11" Opacity="0.6"/>
<ComboBox ItemsSource="{Binding ExistingRuns}" SelectedItem="{Binding SelectedExistingRun}"
PlaceholderText="Neuer Durchgang" HorizontalAlignment="Stretch"/>
</StackPanel>
<Grid ColumnDefinitions="130,170,*" ColumnSpacing="8">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="Stichtag" FontSize="11" Opacity="0.6"/>
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="2">
<TextBlock Text="Anlass" FontSize="11" Opacity="0.6"/>
<ComboBox ItemsSource="{Binding OccasionOptions}" SelectedItem="{Binding OccasionName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock Text="Ergänzung (optional)" FontSize="11" Opacity="0.6"/>
<TextBox Text="{Binding OccasionDetail}" PlaceholderText="z. B. 1. Elternsprechtag"/>
</StackPanel>
</Grid>
</StackPanel>
<StackPanel Grid.Row="2" Spacing="4" Margin="0,16,0,6">
<TextBlock Text="Schüler" FontSize="13" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="Alle" Command="{Binding SelectAllCommand}" FontSize="11" Padding="8,3"/>
<Button Content="Keine" Command="{Binding SelectNoneCommand}" FontSize="11" Padding="8,3"/>
<Button Content="{Binding AtRiskLabel}" Command="{Binding SelectAtRiskCommand}" FontSize="11"
Padding="8,3" Margin="10,0,0,0"
ToolTip.Tip="Wählt genau die Schüler aus, deren vorläufige Note zum Stichtag diese Schwelle erreicht"/>
<TextBox Text="{Binding ThresholdText}" Width="48" FontSize="11"/>
</StackPanel>
<TextBlock Text="{Binding ThresholdTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding ThresholdTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Border Grid.Row="3" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Students}" Margin="8,4">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:InterimRunStudentRow">
<Grid ColumnDefinitions="*,Auto,50" Margin="0,1">
<CheckBox Grid.Column="0" Content="{Binding Name}" IsChecked="{Binding IsSelected}" FontSize="13"/>
<TextBlock Grid.Column="1" Text="{Binding NotedDisplay}" FontSize="11" Foreground="#2E86DE"
VerticalAlignment="Center" Margin="8,0"/>
<TextBlock Grid.Column="2" Text="{Binding ProvisionalDisplay}" FontSize="14" FontWeight="SemiBold"
VerticalAlignment="Center" HorizontalAlignment="Right"
ToolTip.Tip="Vorläufige Note zum Stichtag"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto,Auto" Margin="0,14,0,0">
<TextBlock Grid.Column="0" Text="{Binding SelectionError}" Foreground="Red" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding SelectionError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Abbrechen" Click="OnCancel" Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Schüler durchgehen ▸" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class InterimRunDialog : Window
{
public InterimRunDialog() => InitializeComponent();
private void OnConfirm(object? sender, RoutedEventArgs e)
{
if (DataContext is InterimRunDialogViewModel vm && vm.Confirm()) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -4,7 +4,7 @@
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups" xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.ReportGradeAssistantDialog" x:Class="LehrerApp.Desktop.Views.Groups.ReportGradeAssistantDialog"
x:DataType="vm:ReportGradeAssistantViewModel" x:DataType="vm:ReportGradeAssistantViewModel"
Title="Zeugnisnoten-Assistent" Title="{Binding WindowTitle}"
Width="1280" Height="880" MinWidth="980" MinHeight="640" Width="1280" Height="880" MinWidth="980" MinHeight="640"
CanResize="True" WindowStartupLocation="CenterOwner"> CanResize="True" WindowStartupLocation="CenterOwner">
@@ -19,7 +19,9 @@
<StackPanel Grid.Row="1" Spacing="6" Margin="0,12,0,10"> <StackPanel Grid.Row="1" Spacing="6" Margin="0,12,0,10">
<ComboBox ItemsSource="{Binding PeriodOptions}" SelectedItem="{Binding SelectedPeriod}" <ComboBox ItemsSource="{Binding PeriodOptions}" SelectedItem="{Binding SelectedPeriod}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch" IsVisible="{Binding IsReportMode}"/>
<TextBlock Text="{Binding InterimTitle}" FontSize="13" FontWeight="SemiBold" TextWrapping="Wrap"
Foreground="#D98200" IsVisible="{Binding IsInterimMode}"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="6"> <Grid ColumnDefinitions="Auto,*" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Rundung:" VerticalAlignment="Center" FontSize="12"/> <TextBlock Grid.Column="0" Text="Rundung:" VerticalAlignment="Center" FontSize="12"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding RoundingOptions}" <ComboBox Grid.Column="1" ItemsSource="{Binding RoundingOptions}"
@@ -96,16 +98,53 @@
</ItemsControl> </ItemsControl>
<StackPanel Grid.Column="2" HorizontalAlignment="Right"> <StackPanel Grid.Column="2" HorizontalAlignment="Right">
<TextBlock Text="Zeugnisnote" FontSize="11" Opacity="0.6" HorizontalAlignment="Right"/> <TextBlock Text="Zeugnisnote" FontSize="11" Opacity="0.6" HorizontalAlignment="Right"
IsVisible="{Binding IsReportMode}"/>
<TextBlock Text="{Binding FinalDisplay}" FontSize="34" FontWeight="SemiBold" <TextBlock Text="{Binding FinalDisplay}" FontSize="34" FontWeight="SemiBold"
HorizontalAlignment="Right"/> HorizontalAlignment="Right" IsVisible="{Binding IsReportMode}"/>
<TextBlock Text="{Binding AbsenceRateDisplay}" FontSize="11" HorizontalAlignment="Right" <TextBlock Text="{Binding AbsenceRateDisplay}" FontSize="11" HorizontalAlignment="Right"
Foreground="{Binding AbsenceRateColorHex}" 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."/> ToolTip.Tip="Fehlquote im gewählten Zeitraum — ab 50 % darf laut Schulrecht unabhängig von der fachlichen Leistung eine 5 vergeben werden."/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<Grid ColumnDefinitions="150,*,Auto" ColumnSpacing="6"> <!-- Zwischenstand: vorläufige Noten zum Stichtag, getrennt von der Zeugnisnote -->
<StackPanel Spacing="6" IsVisible="{Binding IsInterimMode}">
<TextBlock FontSize="11" Opacity="0.6" TextWrapping="Wrap"
Text="Vorläufige Noten zum Stichtag — vorbelegt mit dem berechneten Stand, frei anpassbar. Die Zeugnisnote bleibt davon unberührt."/>
<Grid ColumnDefinitions="110,110,110,*" ColumnSpacing="6">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="Schriftlich" FontSize="11" Opacity="0.6"/>
<TextBox Text="{Binding InterimWritten}"/>
<TextBlock Text="{Binding InterimWrittenError}" Foreground="Red" FontSize="11"
IsVisible="{Binding InterimWrittenError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="2">
<TextBlock Text="Mündlich" FontSize="11" Opacity="0.6"/>
<TextBox Text="{Binding InterimOral}"/>
<TextBlock Text="{Binding InterimOralError}" Foreground="Red" FontSize="11"
IsVisible="{Binding InterimOralError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock Text="Gesamt" FontSize="11" FontWeight="SemiBold"/>
<TextBox Text="{Binding InterimOverall}"/>
<TextBlock Text="{Binding InterimOverallError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding InterimOverallError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="3" Spacing="2">
<TextBlock Text="Notiz" FontSize="11" Opacity="0.6"/>
<TextBox Text="{Binding InterimNote}" PlaceholderText="z. B. was besprochen wurde"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="6" HorizontalAlignment="Right">
<Button Content="Entfernen" Command="{Binding RemoveInterimCommand}"
IsVisible="{Binding HasInterimEntry}"/>
<Button Content="Notieren" Command="{Binding NoteInterimCommand}"/>
<Button Content="Notieren &amp; weiter" Command="{Binding NoteInterimAndNextCommand}"/>
</StackPanel>
</StackPanel>
<Grid ColumnDefinitions="150,*,Auto" ColumnSpacing="6" IsVisible="{Binding IsReportMode}">
<TextBox Grid.Column="0" Text="{Binding OverrideValue}" PlaceholderText="Übersteuern (optional)" <TextBox Grid.Column="0" Text="{Binding OverrideValue}" PlaceholderText="Übersteuern (optional)"
IsEnabled="{Binding !IsLocked}"/> IsEnabled="{Binding !IsLocked}"/>
<TextBox Grid.Column="1" Text="{Binding OverrideReason}" PlaceholderText="Begründung" <TextBox Grid.Column="1" Text="{Binding OverrideReason}" PlaceholderText="Begründung"
@@ -154,6 +193,42 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Zwischenstände -->
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Zwischenstände" FontSize="13" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Zwischenstand notieren …" Command="{Binding StartInterimCommand}"
IsVisible="{Binding IsReportMode}" FontSize="11" Padding="8,3"
ToolTip.Tip="Vorläufige Note für diesen Schüler zu einem Stichtag und Anlass festhalten"/>
</Grid>
<TextBlock Text="Noch keine Zwischenstände notiert." FontSize="12" Opacity="0.5"
IsVisible="{Binding !HasInterimEntries}"/>
<ItemsControl ItemsSource="{Binding InterimEntries}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AssistantInterimRow">
<Border BorderBrush="#D98200" BorderThickness="1" CornerRadius="6" Padding="10,5"
Margin="0,0,8,6" MaxWidth="320">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Title}" FontSize="12"/>
<TextBlock Text="{Binding Detail}" FontSize="10" Opacity="0.65"
IsVisible="{Binding Detail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding Note}" FontSize="10" FontStyle="Italic" TextWrapping="Wrap"
IsVisible="{Binding Note, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding OverallValue}" FontSize="17" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="10,0,0,0"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Mitarbeit --> <!-- Mitarbeit -->
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
+1
View File
@@ -131,6 +131,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<Grade>(context => context.Grades); Simple<Grade>(context => context.Grades);
Simple<GradingScheme>(context => context.GradingSchemes); Simple<GradingScheme>(context => context.GradingSchemes);
Simple<ReportGrade>(context => context.ReportGrades); Simple<ReportGrade>(context => context.ReportGrades);
Simple<InterimGrade>(context => context.InterimGrades);
Simple<Unit>(context => context.Units); Simple<Unit>(context => context.Units);
Simple<Lesson>(context => context.Lessons); Simple<Lesson>(context => context.Lessons);
Simple<WorkTask>(context => context.Tasks); Simple<WorkTask>(context => context.Tasks);
+31 -5
View File
@@ -366,11 +366,37 @@ schnelle Listenansicht bestehen bleibt.
zwei Noten geht ungerundet weiter, die Rundungsauswahl bleibt frei, und der Assistent zeigt 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 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. 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 - [x] **2.7.5** Zwischenstand (vorläufige Noten) — für Sprechtage, blaue Briefe, Konferenzen,
laufende Mitarbeits-Abschnitt zählt mit seinem Vorschlag mit) und gespeicherte Notenstände Eltern-/Schülerrückfragen und Abgänge im Halbjahr. Begriffe bewusst getrennt:
(Datum, Anlass wie Sprechtag / Schülerrückfrage / blauer Brief, vorläufige schriftliche, **Zwischenstand** (vorläufig, mehrere je Halbjahr, ohne Einfluss auf die Zeugnisnote) →
mündliche und Gesamtnote, Notiz) — mehrere je Halbjahr, getrennt von der endgültigen **Zeugnisnote speichern** (rechnet bis zur Konferenz weiter mit) → **Festschreiben**
`ReportGrade`. (endgültig). "Festhalten" wurde als Bezeichnung verworfen, weil es nach "fest" klingt.
- Ein Zwischenstand ist immer ein **Durchgang**: Stichtag + Anlass + Schülerauswahl, einmal
festgelegt im Startdialog
([InterimRunDialogViewModel.cs](LehrerApp.Desktop/ViewModels/Groups/InterimRunDialogViewModel.cs)).
Ganze Gruppe, Teilgruppe und Einzelfall sind derselbe Ablauf mit anderer Auswahl;
Schnellfilter "Nur gefährdete" (Schwelle frei wählbar) für blaue Briefe; ein begonnener
Durchgang lässt sich wieder aufnehmen (bereits notierte Schüler sind markiert).
- Einstieg: Button "Zwischenstand …" in der Notenübersicht (mit markiertem Schüler nur
dieser vorausgewählt) und "Zwischenstand notieren …" im Assistenten für den gezeigten
Schüler.
- Der Assistent läuft dann im Zwischenstand-Modus
([ReportGradeAssistantViewModel.Interim.cs](LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModel.Interim.cs))
nur über die gewählten Schüler: Anzeige und Berechnung enden am Stichtag, vorgeschlagen
werden vorläufige schriftliche, mündliche und Gesamtnote, frei anpassbar, mit Notiz;
"Notieren & weiter".
- Berechnung ([InterimGradeCalculator.cs](LehrerApp.Desktop/ViewModels/Groups/InterimGradeCalculator.cs)):
anders als bei der Zeugnisnote zählt der am Stichtag laufende Mitarbeits-Abschnitt mit
seinem Vorschlag wie eine weitere Abschnittsnote mit — sonst bestünde ein Zwischenstand
früh im Halbjahr nur aus dem Schriftlichen.
- Gespeichert als neues Modell `InterimGrade` (eigene Collection, Sync, Kaskade beim
Löschen der Gruppe; zählt bei den Schüler-Referenzen unter "Zeugnisnoten und
Zwischenstände"). Notierte Zwischenstände erscheinen im Assistenten als Liste je Schüler
und als Marker im Mitarbeitsverlauf.
- Notenübersicht (2.1): je Durchgang eine graue Spalte "Zwischenstand <Datum> <Anlass>"
hinter den übrigen Noten — nicht in "Gesamt" eingerechnet und nicht im PDF der Notenliste.
- Nebenbei behoben: ohne abgeschlossenen Abschnitt fielen Arbeiten vor der ersten
Mitarbeit-Sitzung aus dem Verlauf des Assistenten.
- [ ] **2.7.6** Noch im Detail zu besprechen: Grenzfall-Filter, Trendhinweise, Begründungsvorschlag - [ ] **2.7.6** Noch im Detail zu besprechen: Grenzfall-Filter, Trendhinweise, Begründungsvorschlag
beim Übersteuern, Kompetenzblock (Stärken / Baustellen), Gesprächsblatt als PDF, beim Übersteuern, Kompetenzblock (Stärken / Baustellen), Gesprächsblatt als PDF,
Schülermodus. Schülermodus.
+12
View File
@@ -114,6 +114,18 @@ zwischen zwei Noten geht ungerundet in die Rechnung ein; für die Endnote gilt d
Lehrkraft gewählte Rundung, und der Assistent weist darauf hin, wenn der Wert genau zwischen 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. zwei Noten liegt. Stand der Verordnungen: Oberschulverordnung in der Fassung von Juni 2025.
## Zwischenstand (`InterimGrade`)
Ein Zwischenstand ist eine vorläufige Note zu einem Stichtag (`Date`), wie sie zu einem Anlass
(`Occasion`, freier Text wie "Sprechtag" oder "Elternanfrage: Frau Muster") genannt wurde. Er ist
bewusst kein Zustand der Zeugnisnote, sondern ein eigener Datensatz: es kann mehrere je Halbjahr
geben, und keiner beeinflusst `ReportGrade`. Zwischenstände einer Lerngruppe mit gleichem `Date`
und `Occasion` bilden zusammen einen Durchgang — es gibt dafür keinen eigenen Datensatz.
`WrittenValue`, `OralValue` und `OverallValue` sind die tatsächlich genannten Noten, nicht der
berechnete Vorschlag; die Lehrkraft kann sie beim Notieren anpassen. `OralValue` meint die
Mitarbeit inklusive des am Stichtag laufenden, noch nicht abgeschlossenen Abschnitts.
## Bewusst gespeicherte Momentaufnahmen ## Bewusst gespeicherte Momentaufnahmen
Einige berechnete Werte bleiben absichtlich gespeichert: Einige berechnete Werte bleiben absichtlich gespeichert: