Notenverwaltung (Kapitel 2) und Mitarbeits-Assistent

Notenübersicht der Gruppe (Matrix, Gesamt-Spalte, Sortierung, Halbjahresfilter),
Einzelnoten-Pflege samt Sammelerfassung, Gewichtungsschema mit Voreinstellung je
Gruppentyp, Zeugnisnoten-Berechnung mit Übersteuern/Festschreiben/Export und
Notenentwicklung im Schülerdetail.

Dazu Anwesenheits-/Hausaufgaben-Tracking je Mitarbeit-Sitzung, ein neuer
Mitarbeits-Assistent (Zeitleiste mit Abschnitten, automatischer Notenvorschlag,
Zusammenzug zur Halbjahresnote) und eine Dashboard-Kachel für offene
Entschuldigungen. Außerdem: verbliebene englische Begriffe in Auswahlfeldern
und Buttons auf Deutsch umgestellt.
This commit is contained in:
2026-08-12 19:19:22 +02:00
parent a1e722ace1
commit febcb0855a
34 changed files with 2503 additions and 39 deletions
@@ -56,6 +56,20 @@ public interface IGradeRepository
void Save(Grade grade); void Save(Grade grade);
void Delete(Guid id); void Delete(Guid id);
} }
public interface IGradingSchemeRepository
{
GradingScheme? GetByGroup(Guid groupId);
GradingScheme? GetDefaultForType(GroupType type);
void Save(GradingScheme scheme);
void Delete(Guid id);
}
public interface IReportGradeRepository
{
List<ReportGrade> GetByGroup(Guid groupId);
ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period);
void Save(ReportGrade grade);
void Delete(Guid id);
}
public interface IUnitRepository public interface IUnitRepository
{ {
Unit? GetById(Guid id); Unit? GetById(Guid id);
@@ -116,6 +130,12 @@ public interface IParticipationAspectRepository
void Save(ParticipationAspect aspect); void Save(ParticipationAspect aspect);
void Delete(Guid id); void Delete(Guid id);
} }
public interface IParticipationSectionRepository
{
List<ParticipationSection> GetByGroup(Guid groupId);
void Save(ParticipationSection section);
void Delete(Guid id);
}
public interface ISubjectRepository public interface ISubjectRepository
{ {
List<Subject> GetAll(); List<Subject> GetAll();
+25
View File
@@ -20,9 +20,34 @@ public class ParticipationEntry
public List<AspectRating> Ratings { get; set; } = []; public List<AspectRating> Ratings { get; set; } = [];
public List<CompetencyRating> CompetencyRatings { get; set; } = []; public List<CompetencyRating> CompetencyRatings { get; set; } = [];
public string? Note { get; set; } public string? Note { get; set; }
public bool HomeworkMissing { get; set; }
public AttendanceStatus? Attendance { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>
/// Anwesenheitsstatus einer Sitzung; null (Standard) bedeutet anwesend.
/// <see cref="ExcusePending"/> ist ein bewusster Zwischenzustand, da die Entschuldigung meist
/// erst später eintrifft — er wird beim Erfassen des Fehltags gesetzt und danach manuell auf
/// <see cref="Excused"/> oder <see cref="Unexcused"/> nachgetragen.
/// </summary>
public enum AttendanceStatus { ExcusePending, Excused, Unexcused }
/// <summary>
/// Ein Bewertungsabschnitt einer Lerngruppe (z.B. alle 47 Wochen), an dessen Ende eine
/// Abschnittsnote Mitarbeit vergeben wird. Nur abgeschlossene Abschnitte werden gespeichert;
/// der aktuell laufende Zeitraum ergibt sich aus dem Ende des letzten Abschnitts bis heute.
/// </summary>
public class ParticipationSection
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public string Label { get; set; } = "";
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class CompetencyRating public class CompetencyRating
{ {
public string Code { get; set; } = ""; public string Code { get; set; } = "";
+35
View File
@@ -14,6 +14,22 @@ public class Grade
} }
public enum GradeCategory { Oral, Homework, Participation, Project, Other } public enum GradeCategory { Oral, Homework, Participation, Project, Other }
/// <summary>
/// Prozentuale Gewichtung von Klausuren/Mitarbeit/sonstigen Leistungen für die Zeugnisnote.
/// Entweder gruppenspezifisch (<see cref="GroupId"/> gesetzt) oder als Voreinstellung je
/// Gruppentyp (<see cref="GroupType"/> gesetzt, <see cref="GroupId"/> null).
/// </summary>
public class GradingScheme
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? GroupId { get; set; }
public GroupType? GroupType { get; set; }
public double ExamsPercent { get; set; }
public double ParticipationPercent { get; set; }
public double OtherPercent { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Unit public class Unit
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -45,3 +61,22 @@ public class Lesson
} }
public enum UnitStatus { Planned, Active, Completed } public enum UnitStatus { Planned, Active, Completed }
public enum LessonStatus { Planned, Conducted } public enum LessonStatus { Planned, Conducted }
/// <summary>
/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr).
/// <see cref="CalculatedValue"/> ist das zuletzt berechnete Ergebnis; <see cref="OverrideValue"/>
/// überschreibt es bei pädagogischem Ermessen (erfordert <see cref="OverrideReason"/>).
/// Nach dem Festschreiben (<see cref="IsLocked"/>) wird der Datensatz nicht mehr neu berechnet.
/// </summary>
public class ReportGrade
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
public Guid GroupId { get; set; }
public string Period { get; set; } = "";
public string CalculatedValue { get; set; } = "";
public string? OverrideValue { get; set; }
public string? OverrideReason { get; set; }
public bool IsLocked { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+57
View File
@@ -64,6 +64,16 @@ public class GradingService
return CalculateGrade(percent, 100, key); return CalculateGrade(percent, 100, key);
} }
public string? ValidateGradingScheme(GradingScheme scheme)
{
if (scheme.ExamsPercent < 0 || scheme.ParticipationPercent < 0 || scheme.OtherPercent < 0)
return "Anteile dürfen nicht negativ sein.";
var sum = scheme.ExamsPercent + scheme.ParticipationPercent + scheme.OtherPercent;
return Math.Abs(sum - 100.0) > 0.01
? $"Die Anteile müssen zusammen 100 % ergeben (aktuell {sum.ToString("0.#")} %)."
: null;
}
public double WeightedAverage(List<(string Grade, double Weight)> grades) public double WeightedAverage(List<(string Grade, double Weight)> grades)
{ {
var numeric = grades var numeric = grades
@@ -73,4 +83,51 @@ public class GradingService
var total = numeric.Sum(g => g.Weight); var total = numeric.Sum(g => g.Weight);
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total; return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
} }
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
/// (Grades1To6: kleinere Zahl ist besser → abrunden; Points0To15: größere Zahl ist besser → aufrunden).
public string RoundToGrade(double value, GradingSystem system, RoundingRule rule)
{
int rounded;
var floor = Math.Floor(value);
var isExactHalf = Math.Abs(value - floor - 0.5) < 0.0001;
if (rule == RoundingRule.Pedagogical && isExactHalf)
{
var betterIsLower = system == GradingSystem.Grades1To6;
rounded = betterIsLower ? (int)floor : (int)floor + 1;
} }
else
{
rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero);
}
var (min, max) = system == GradingSystem.Grades1To6 ? (1, 6) : (0, 15);
return Math.Clamp(rounded, min, max).ToString();
}
/// Berechnet die Zeugnisnote (2.4.1) aus den drei Leistungsbereichen gemäß Gewichtungsschema.
/// Bereiche ohne Werte werden ausgelassen; die verbleibenden Prozentanteile werden neu normiert.
/// Liefert null, wenn in keinem Bereich Werte vorliegen.
public string? CalculateReportGrade(
List<(string Grade, double Weight)> examGrades,
List<(string Grade, double Weight)> participationGrades,
List<(string Grade, double Weight)> otherGrades,
GradingScheme scheme, GradingSystem system, RoundingRule rounding)
{
var buckets = new List<(double Avg, double Percent)>();
if (examGrades.Count > 0) buckets.Add((WeightedAverage(examGrades), scheme.ExamsPercent));
if (participationGrades.Count > 0) buckets.Add((WeightedAverage(participationGrades), scheme.ParticipationPercent));
if (otherGrades.Count > 0) buckets.Add((WeightedAverage(otherGrades), scheme.OtherPercent));
var totalPercent = buckets.Sum(b => b.Percent);
if (buckets.Count == 0 || totalPercent <= 0) return null;
var weighted = buckets.Sum(b => b.Avg * b.Percent) / totalPercent;
return RoundToGrade(weighted, system, rounding);
}
}
public enum RoundingRule { Commercial, Pedagogical }
+10
View File
@@ -26,6 +26,8 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams"); public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results"); public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
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<ReportGrade> ReportGrades => _db.GetCollection<ReportGrade>("report_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");
@@ -35,6 +37,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions"); public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions");
public ILiteCollection<ParticipationEntry> ParticipationEntries => _db.GetCollection<ParticipationEntry>("participation"); public ILiteCollection<ParticipationEntry> ParticipationEntries => _db.GetCollection<ParticipationEntry>("participation");
public ILiteCollection<ParticipationAspect> ParticipationAspects => _db.GetCollection<ParticipationAspect>("participation_aspects"); public ILiteCollection<ParticipationAspect> ParticipationAspects => _db.GetCollection<ParticipationAspect>("participation_aspects");
public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections");
public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects"); public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects");
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains"); public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
@@ -152,6 +155,12 @@ public class LiteDbContext : IDisposable
BsonExpression.Create("STRING($.ExamId) + ':' + STRING($.StudentId)"), unique: true); BsonExpression.Create("STRING($.ExamId) + ':' + STRING($.StudentId)"), unique: true);
Grades.EnsureIndex(x => x.StudentId); Grades.EnsureIndex(x => x.StudentId);
Grades.EnsureIndex(x => x.GroupId); Grades.EnsureIndex(x => x.GroupId);
GradingSchemes.EnsureIndex(x => x.GroupId);
GradingSchemes.EnsureIndex(x => x.GroupType);
ReportGrades.EnsureIndex(x => x.GroupId);
ReportGrades.EnsureIndex(x => x.StudentId);
ReportGrades.EnsureIndex("ux_student_group_period",
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true);
GradingKeyTemplates.EnsureIndex(x => x.GradingSystem); 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);
@@ -167,6 +176,7 @@ public class LiteDbContext : IDisposable
ParticipationEntries.EnsureIndex("ux_session_student", ParticipationEntries.EnsureIndex("ux_session_student",
BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true); BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true);
ParticipationAspects.EnsureIndex(x => x.GroupId); ParticipationAspects.EnsureIndex(x => x.GroupId);
ParticipationSections.EnsureIndex(x => x.GroupId);
Subjects.EnsureIndex(x => x.Name); Subjects.EnsureIndex(x => x.Name);
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
CompetencyDomains.EnsureIndex(x => x.SubjectId); CompetencyDomains.EnsureIndex(x => x.SubjectId);
@@ -147,6 +147,24 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
public void Delete(Guid id) => db.Grades.Delete(id); public void Delete(Guid id) => db.Grades.Delete(id);
} }
public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository
{
public GradingScheme? GetByGroup(Guid groupId) => db.GradingSchemes.FindOne(s => s.GroupId == groupId);
public GradingScheme? GetDefaultForType(GroupType type) =>
db.GradingSchemes.FindOne(s => s.GroupId == null && s.GroupType == type);
public void Save(GradingScheme s) { s.UpdatedAt = DateTime.UtcNow; db.GradingSchemes.Upsert(s); }
public void Delete(Guid id) => db.GradingSchemes.Delete(id);
}
public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository
{
public List<ReportGrade> GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList();
public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) =>
db.ReportGrades.FindOne(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == period);
public void Save(ReportGrade r) { r.UpdatedAt = DateTime.UtcNow; db.ReportGrades.Upsert(r); }
public void Delete(Guid id) => db.ReportGrades.Delete(id);
}
public class UnitRepository(LiteDbContext db) : IUnitRepository public class UnitRepository(LiteDbContext db) : IUnitRepository
{ {
public Unit? GetById(Guid id) => db.Units.FindById(id); public Unit? GetById(Guid id) => db.Units.FindById(id);
@@ -248,6 +266,14 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp
public void Delete(Guid id) => db.ParticipationAspects.Delete(id); public void Delete(Guid id) => db.ParticipationAspects.Delete(id);
} }
public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSectionRepository
{
public List<ParticipationSection> GetByGroup(Guid groupId) =>
db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList();
public void Save(ParticipationSection s) => db.ParticipationSections.Upsert(s);
public void Delete(Guid id) => db.ParticipationSections.Delete(id);
}
public class SubjectRepository(LiteDbContext db) : ISubjectRepository public class SubjectRepository(LiteDbContext db) : ISubjectRepository
{ {
public List<Subject> GetAll() => db.Subjects.FindAll().OrderBy(s => s.Name).ToList(); public List<Subject> GetAll() => db.Subjects.FindAll().OrderBy(s => s.Name).ToList();
+4
View File
@@ -48,6 +48,8 @@ public static class AppBootstrapper
services.AddSingleton<IExamRepository, ExamRepository>(); services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>(); services.AddSingleton<IExamResultRepository, ExamResultRepository>();
services.AddSingleton<IGradeRepository, GradeRepository>(); services.AddSingleton<IGradeRepository, GradeRepository>();
services.AddSingleton<IGradingSchemeRepository, GradingSchemeRepository>();
services.AddSingleton<IReportGradeRepository, ReportGradeRepository>();
services.AddSingleton<IGradingKeyTemplateRepository, GradingKeyTemplateRepository>(); services.AddSingleton<IGradingKeyTemplateRepository, GradingKeyTemplateRepository>();
services.AddSingleton<IUnitRepository, UnitRepository>(); services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>(); services.AddSingleton<ILessonRepository, LessonRepository>();
@@ -57,6 +59,7 @@ public static class AppBootstrapper
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>(); services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
services.AddSingleton<IParticipationRepository, ParticipationRepository>(); services.AddSingleton<IParticipationRepository, ParticipationRepository>();
services.AddSingleton<IParticipationAspectRepository, ParticipationAspectRepository>(); services.AddSingleton<IParticipationAspectRepository, ParticipationAspectRepository>();
services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>();
services.AddSingleton<ISubjectRepository, SubjectRepository>(); services.AddSingleton<ISubjectRepository, SubjectRepository>();
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>(); services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
@@ -111,6 +114,7 @@ public static class AppBootstrapper
services.AddTransient<GroupDetailViewModel>(); services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentDetailViewModel>(); services.AddTransient<StudentDetailViewModel>();
services.AddTransient<ParticipationTabViewModel>(); services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
@@ -17,8 +17,13 @@ public partial class DashboardViewModel : ObservableObject
private readonly ILessonRepository _lessons; private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks; private readonly IWorkTaskRepository _tasks;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participationEntries;
private readonly IStudentRepository _students;
private readonly SchoolYearService _sy; private readonly SchoolYearService _sy;
private const int OpenExcuseMaxAgeDays = 21;
[ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private string _currentSchoolYear = "";
@@ -30,15 +35,19 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<TaskItem> OpenTasks { get; } = []; public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = []; public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = []; public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet // Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IWorkTaskRepository tasks, SchoolYearService sy) IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
IParticipationRepository participationEntries, IStudentRepository students, SchoolYearService sy)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _sy = sy; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_participationSessions = participationSessions; _participationEntries = participationEntries;
_students = students; _sy = sy;
Load(); Load();
} }
@@ -80,6 +89,41 @@ public partial class DashboardViewModel : ObservableObject
CalendarMonth = FirstOfMonth(now); CalendarMonth = FirstOfMonth(now);
LoadCalendar(); LoadCalendar();
LoadOpenExcuses(groups.Values.ToList(), today);
}
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{
OpenExcuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>();
foreach (var g in groups)
{
foreach (var session in _participationSessions.GetByGroup(g.Id).Where(s => s.Date >= cutoff && s.Date <= today))
{
foreach (var entry in _participationEntries.GetBySession(session.Id)
.Where(e => e.Attendance == AttendanceStatus.ExcusePending))
{
var student = _students.GetById(entry.StudentId);
if (student is null) continue;
var item = new OpenExcuseItem(session.Id, entry.StudentId, student.FullName, g.Name, session.Date);
item.OnResolve = ResolveExcuse;
items.Add(item);
}
}
}
foreach (var item in items.OrderBy(i => i.Date))
OpenExcuses.Add(item);
}
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
{
var entry = _participationEntries.GetBySessionAndStudent(item.SessionId, item.StudentId);
if (entry is null) return;
entry.Attendance = status;
_participationEntries.Save(entry);
OpenExcuses.Remove(item);
} }
private void LoadCalendar() private void LoadCalendar()
@@ -172,6 +216,33 @@ public class LessonItem { public string GroupName { get; set; } = ""; public str
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } } public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } }
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; } public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
public partial class OpenExcuseItem : ObservableObject
{
public Guid SessionId { get; }
public Guid StudentId { get; }
public string StudentName { get; }
public string GroupName { get; }
public DateOnly Date { get; }
public string DateDisplay { get; }
public Action<OpenExcuseItem, AttendanceStatus>? OnResolve { get; set; }
public OpenExcuseItem(Guid sessionId, Guid studentId, string studentName, string groupName, DateOnly date)
{
SessionId = sessionId;
StudentId = studentId;
StudentName = studentName;
GroupName = groupName;
Date = date;
DateDisplay = date.ToString("dd.MM.yyyy");
}
[RelayCommand] private void MarkExcused() => OnResolve?.Invoke(this, AttendanceStatus.Excused);
[RelayCommand] private void MarkUnexcused() => OnResolve?.Invoke(this, AttendanceStatus.Unexcused);
}
public class CalendarDayCell public class CalendarDayCell
{ {
public int DayNumber { get; } public int DayNumber { get; }
@@ -0,0 +1,461 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Notenübersicht der Gruppe (2.1) ──────────────────────────────────────────
public partial class GradeOverviewTabViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly GradingService _grading;
private Guid _groupId;
private GradingSystem _gradingSystem;
private GroupType _groupType;
private string _groupLabel = "";
private bool _sortByTotal;
private bool _sortDescending;
public Guid GroupId => _groupId;
public GradingSystem GradingSystem => _gradingSystem;
public GroupType GroupType => _groupType;
public string GroupLabel => _groupLabel;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private bool _showAsPoints = true;
[ObservableProperty] private GradeOverviewRow? _selectedRow;
[ObservableProperty] private int _rebuildColumnsSignal;
public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15;
public List<ParticipationPeriodOption> PeriodOptions { get; } =
[
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
];
public ObservableCollection<GradeOverviewColumnDef> Columns { get; } = [];
public ObservableCollection<GradeOverviewRow> Rows { get; } = [];
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
public Func<Task>? OnCollectiveGrade { get; set; }
public Func<Task>? OnReportGrades { get; set; }
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students,
IGroupMembershipRepository memberships, GradingService grading)
{
_grades = grades; _exams = exams; _results = results;
_students = students; _memberships = memberships; _grading = grading;
_selectedPeriod = PeriodOptions[0];
}
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType, string groupLabel)
{
_groupId = groupId;
_gradingSystem = gradingSystem;
_groupType = groupType;
_groupLabel = groupLabel;
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
OnPropertyChanged(nameof(CanTogglePointsView));
Recompute();
}
public void Refresh() => Recompute();
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
partial void OnShowAsPointsChanged(bool value) => Recompute();
[RelayCommand]
private void SortByName()
{
if (!_sortByTotal) _sortDescending = !_sortDescending;
else { _sortByTotal = false; _sortDescending = false; }
ApplySort();
}
[RelayCommand]
private void SortByTotal()
{
if (_sortByTotal) _sortDescending = !_sortDescending;
else { _sortByTotal = true; _sortDescending = false; }
ApplySort();
}
[RelayCommand]
private async Task ManageStudentGrades()
{
if (SelectedRow is null || OnManageStudentGrades is null) return;
await OnManageStudentGrades(SelectedRow);
Recompute();
}
[RelayCommand]
private async Task CollectiveGrade()
{
if (OnCollectiveGrade is null) return;
await OnCollectiveGrade();
Recompute();
}
[RelayCommand]
private async Task ReportGrades()
{
if (OnReportGrades is null) return;
await OnReportGrades();
}
private void Recompute()
{
var period = SelectedPeriod.Period;
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
var exams = _exams.GetByGroup(_groupId)
.Where(e => InPeriod(e.Date, period))
.OrderBy(e => e.Date)
.ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var otherGrades = _grades.GetByGroup(_groupId)
.Where(g => InPeriod(g.Date, period))
.ToList();
var gradeColumnKeys = otherGrades
.Select(g => (g.Category, Note: g.Note ?? "", g.Date))
.Distinct()
.OrderBy(k => k.Date)
.ToList();
Columns.Clear();
foreach (var exam in exams)
Columns.Add(new GradeOverviewColumnDef($"{exam.Date:dd.MM.} {exam.Title}"));
foreach (var key in gradeColumnKeys)
{
var header = string.IsNullOrWhiteSpace(key.Note)
? $"{GradeCategoryDisplay.Label(key.Category)} {key.Date:dd.MM.}"
: key.Note;
Columns.Add(new GradeOverviewColumnDef(header));
}
Rows.Clear();
foreach (var student in students)
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (!StudentActiveInPeriod(membership, period)) continue;
var cells = new List<string>();
var numeric = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) { cells.Add(""); continue; }
resultsByExam[exam.Id].TryGetValue(student.Id, out var result);
if (result is null) { cells.Add(""); continue; }
if (result.Absent) { cells.Add("abwesend"); continue; }
var display = FormatValue(result.Grade);
cells.Add(display);
if (result.Grade is not null) numeric.Add((result.Grade, 1.0));
}
foreach (var key in gradeColumnKeys)
{
var grade = otherGrades.FirstOrDefault(g =>
g.StudentId == student.Id && g.Category == key.Category &&
(g.Note ?? "") == key.Note && g.Date == key.Date);
if (grade is null) { cells.Add(""); continue; }
cells.Add(FormatValue(grade.Value));
numeric.Add((grade.Value, grade.Weight));
}
var total = numeric.Count == 0 ? (double?)null : _grading.WeightedAverage(numeric);
var totalDisplay = total is null ? "" : total.Value.ToString("0.00", CultureInfo.InvariantCulture);
Rows.Add(new GradeOverviewRow(student.Id, student.FullName, cells, totalDisplay, total));
}
ApplySort();
RebuildColumnsSignal++;
}
private string FormatValue(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "";
if (!ShowAsPoints && _gradingSystem == GradingSystem.Points0To15 && int.TryParse(raw, out var points))
return PointsNoteMapping.PointsToNote(points);
return raw;
}
private void ApplySort()
{
var sorted = _sortByTotal
? Rows.OrderBy(r => r.TotalSortValue is null).ThenBy(r => r.TotalSortValue).ToList()
: Rows.OrderBy(r => r.Name).ToList();
if (_sortDescending) sorted.Reverse();
for (var i = 0; i < sorted.Count; i++) Rows.Move(Rows.IndexOf(sorted[i]), i);
}
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
{
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
{
if (period == ParticipationPeriod.FullYear || m is null) return true;
return m.Period switch
{
MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
_ => true,
};
}
}
public class GradeOverviewColumnDef(string header)
{
public string Header { get; } = header;
}
public class GradeOverviewRow(Guid studentId, string name, List<string> cells, string totalDisplay, double? totalSortValue)
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
public List<string> Cells { get; } = cells;
public string TotalDisplay { get; } = totalDisplay;
public double? TotalSortValue { get; } = totalSortValue;
}
// ── Kategorie-Anzeige & Punkte/Noten-Umrechnung ──────────────────────────────
public static class GradeCategoryDisplay
{
public static string Label(GradeCategory c) => c switch
{
GradeCategory.Oral => "Mündlich",
GradeCategory.Homework => "Hausaufgaben",
GradeCategory.Participation => "Mitarbeit",
GradeCategory.Project => "Projekt",
GradeCategory.Other => "Sonstiges",
_ => c.ToString(),
};
public static string[] Options { get; } = Enum.GetValues<GradeCategory>().Select(Label).ToArray();
public static GradeCategory FromLabel(string? label) =>
Enum.GetValues<GradeCategory>().FirstOrDefault(c => Label(c) == label, GradeCategory.Other);
}
public static class PointsNoteMapping
{
// Grobe, standardübliche Punkte-Noten-Umrechnung (Oberstufe), nur für die Anzeige.
public static string PointsToNote(int points) => points switch
{
>= 13 => "1",
>= 10 => "2",
>= 7 => "3",
>= 4 => "4",
>= 1 => "5",
_ => "6",
};
}
// ── Note hinzufügen/bearbeiten für einen Schüler (2.2.1, 2.2.2) ──────────────
public partial class StudentGradesDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly Guid _studentId;
private readonly Guid _groupId;
public string StudentName { get; }
public ObservableCollection<GradeEditItem> Entries { get; } = [];
public StudentGradesDialogViewModel(IGradeRepository grades, Guid studentId, Guid groupId, string studentName)
{
_grades = grades; _studentId = studentId; _groupId = groupId;
StudentName = studentName;
Load();
}
private void Load()
{
Entries.Clear();
foreach (var g in _grades.GetByStudentAndGroup(_studentId, _groupId).OrderByDescending(g => g.Date))
Entries.Add(new GradeEditItem(g) { OnSave = Save, OnDelete = Delete });
}
[RelayCommand]
private void AddEntry()
{
var item = new GradeEditItem(new Grade
{
StudentId = _studentId,
GroupId = _groupId,
Category = GradeCategory.Other,
Date = DateOnly.FromDateTime(DateTime.Today),
})
{ OnSave = Save, OnDelete = Delete, IsNew = true };
Entries.Insert(0, item);
}
private void Save(GradeEditItem item)
{
if (string.IsNullOrWhiteSpace(item.Value)) { item.ValidationMessage = "Wert darf nicht leer sein."; return; }
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out _))
{ item.ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
item.ValidationMessage = "";
var grade = item.ToModel();
_grades.Save(grade);
item.IsNew = false;
item.MarkSaved(grade.CreatedAt);
}
private void Delete(GradeEditItem item)
{
if (!item.IsNew) _grades.Delete(item.Id);
Entries.Remove(item);
}
}
public partial class GradeEditItem : ObservableObject
{
public Guid Id { get; }
private readonly Guid _studentId;
private readonly Guid _groupId;
public bool IsNew { get; set; }
[ObservableProperty] private GradeCategory _category;
[ObservableProperty] private string _value;
[ObservableProperty] private string _dateText;
[ObservableProperty] private double _weight;
[ObservableProperty] private string? _note;
[ObservableProperty] private string _validationMessage = "";
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string CategoryName
{
get => GradeCategoryDisplay.Label(Category);
set => Category = GradeCategoryDisplay.FromLabel(value);
}
public string CreatedAtDisplay { get; private set; }
public Action<GradeEditItem>? OnSave { get; set; }
public Action<GradeEditItem>? OnDelete { get; set; }
public GradeEditItem(Grade g)
{
Id = g.Id;
_studentId = g.StudentId;
_groupId = g.GroupId;
_category = g.Category;
_value = g.Value;
_dateText = g.Date.ToString("dd.MM.yyyy");
_weight = g.Weight;
_note = g.Note;
CreatedAtDisplay = $"erfasst am {g.CreatedAt.ToLocalTime():dd.MM.yyyy HH:mm}";
}
public void MarkSaved(DateTime createdAt) => CreatedAtDisplay = $"erfasst am {createdAt.ToLocalTime():dd.MM.yyyy HH:mm}";
public Grade ToModel() => new()
{
Id = Id,
StudentId = _studentId,
GroupId = _groupId,
Category = Category,
Value = Value.Trim(),
Date = DateOnly.ParseExact(DateText, "dd.MM.yyyy"),
Weight = Weight,
Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(),
};
[RelayCommand]
private void Save() => OnSave?.Invoke(this);
[RelayCommand]
private void Delete() => OnDelete?.Invoke(this);
}
// ── Sammelerfassung für die ganze Gruppe (2.2.3) ─────────────────────────────
public partial class CollectiveGradeDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly Guid _groupId;
[ObservableProperty] private GradeCategory _category = GradeCategory.Other;
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private double _weight = 1.0;
[ObservableProperty] private string? _note;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private string _validationMessage = "";
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string CategoryName
{
get => GradeCategoryDisplay.Label(Category);
set => Category = GradeCategoryDisplay.FromLabel(value);
}
public ObservableCollection<CollectiveGradeStudentRow> Rows { get; } = [];
public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students, Guid groupId)
{
_grades = grades; _groupId = groupId;
foreach (var s in students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
Rows.Add(new CollectiveGradeStudentRow(s.Id, s.FullName));
}
[RelayCommand]
private void SaveAll()
{
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
{ ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
ValidationMessage = "";
var count = 0;
foreach (var row in Rows)
{
if (string.IsNullOrWhiteSpace(row.Value)) continue;
_grades.Save(new Grade
{
StudentId = row.StudentId,
GroupId = _groupId,
Category = Category,
Value = row.Value.Trim(),
Date = date,
Weight = Weight,
Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(),
});
count++;
}
StatusMessage = count == 0
? "Keine Werte eingegeben."
: $"{count} Note(n) gespeichert.";
}
}
public partial class CollectiveGradeStudentRow(Guid studentId, string name) : ObservableObject
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
[ObservableProperty] private string _value = "";
}
@@ -182,6 +182,7 @@ public partial class GroupDetailViewModel : ObservableObject
public ObservableCollection<ExamSummary> Exams { get; } = []; public ObservableCollection<ExamSummary> Exams { get; } = [];
public ParticipationTabViewModel ParticipationTab { get; } public ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; } public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; } public Func<Guid, Task<bool>>? OnAddExam { get; set; }
public Func<Exam, Task<bool>>? OnEditExam { get; set; } public Func<Exam, Task<bool>>? OnEditExam { get; set; }
@@ -193,11 +194,12 @@ public partial class GroupDetailViewModel : ObservableObject
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects, IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades, IExamRepository exams, IGradeRepository grades,
ParticipationTabViewModel participationTab) ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab)
{ {
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _exams = exams; _grades = grades;
ParticipationTab = participationTab; ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab;
} }
public void LoadGroup(Guid id) public void LoadGroup(Guid id)
@@ -214,6 +216,7 @@ public partial class GroupDetailViewModel : ObservableObject
LoadStudents(); LoadStudents();
ReloadExams(); ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear); ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
} }
private void ReloadExams() private void ReloadExams()
@@ -28,6 +28,7 @@ public partial class ParticipationTabViewModel : ObservableObject
public Guid GroupId => _groupId; public Guid GroupId => _groupId;
public string SchoolYear => _schoolYear; public string SchoolYear => _schoolYear;
public GradingSystem GradingSystem => _gradingSystem; public GradingSystem GradingSystem => _gradingSystem;
public string GroupLabel { get; private set; } = "";
[ObservableProperty] private ParticipationSessionItem? _selectedSession; [ObservableProperty] private ParticipationSessionItem? _selectedSession;
[ObservableProperty] private string _noDataText = "Keine Bewertungssitzung ausgewählt."; [ObservableProperty] private string _noDataText = "Keine Bewertungssitzung ausgewählt.";
@@ -47,6 +48,7 @@ public partial class ParticipationTabViewModel : ObservableObject
public Func<Task<ParticipationSession?>>? OnAddSession { get; set; } public Func<Task<ParticipationSession?>>? OnAddSession { get; set; }
public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; } public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; }
public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; } public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; }
public Func<ParticipationTabViewModel, Task>? OnOpenWizard { get; set; }
public ParticipationTabViewModel( public ParticipationTabViewModel(
IParticipationSessionRepository sessions, IParticipationSessionRepository sessions,
@@ -72,6 +74,7 @@ public partial class ParticipationTabViewModel : ObservableObject
_subjectId = group?.SubjectId; _subjectId = group?.SubjectId;
_gradeLevel = group?.GradeLevel ?? 0; _gradeLevel = group?.GradeLevel ?? 0;
_gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6; _gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6;
GroupLabel = group?.Name ?? "";
HasCompetencyCatalog = _subjectId.HasValue HasCompetencyCatalog = _subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel).Count > 0; && _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel).Count > 0;
@@ -143,6 +146,8 @@ public partial class ParticipationTabViewModel : ObservableObject
var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes); var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes);
row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val); row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val);
row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val); row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val);
row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val);
row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val);
StudentRows.Add(row); StudentRows.Add(row);
} }
QuickInputCommand.NotifyCanExecuteChanged(); QuickInputCommand.NotifyCanExecuteChanged();
@@ -185,6 +190,22 @@ public partial class ParticipationTabViewModel : ObservableObject
if (SelectedSession is not null) LoadGrid(SelectedSession.Id); if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
} }
private void SaveHomework(Guid sessionId, Guid studentId, bool value)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.HomeworkMissing = value;
_entries.Save(entry);
}
private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Attendance = value;
_entries.Save(entry);
}
[RelayCommand] [RelayCommand]
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible; private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
@@ -259,6 +280,13 @@ public partial class ParticipationTabViewModel : ObservableObject
await OnComputeGrade(this); await OnComputeGrade(this);
} }
[RelayCommand]
private async Task OpenWizard()
{
if (OnOpenWizard is null) return;
await OnOpenWizard(this);
}
[RelayCommand] [RelayCommand]
private void DeleteSession() private void DeleteSession()
{ {
@@ -310,8 +338,16 @@ public partial class ParticipationStudentRow : ObservableObject
public ObservableCollection<RatingCell> Cells { get; } = []; public ObservableCollection<RatingCell> Cells { get; } = [];
public ObservableCollection<RatingCell> CompetencyCells { get; } = []; public ObservableCollection<RatingCell> CompetencyCells { get; } = [];
[ObservableProperty] private bool _homeworkMissing;
[ObservableProperty] private AttendanceStatus? _attendance;
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
public Action<Guid, string, int?>? OnRatingChanged { get; set; } public Action<Guid, string, int?>? OnRatingChanged { get; set; }
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; } public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
public Action<Guid, bool>? HomeworkChangedCallback { get; set; }
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry, public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
List<AspectColumnDef> aspects, List<string> competencyCodes) List<AspectColumnDef> aspects, List<string> competencyCodes)
@@ -320,6 +356,8 @@ public partial class ParticipationStudentRow : ObservableObject
Name = name; Name = name;
_entry = entry; _entry = entry;
_aspectDefs = aspects; _aspectDefs = aspects;
_homeworkMissing = entry.HomeworkMissing;
_attendance = entry.Attendance;
foreach (var a in aspects) foreach (var a in aspects)
{ {
@@ -347,6 +385,61 @@ public partial class ParticipationStudentRow : ObservableObject
cell?.SetValue(value); cell?.SetValue(value);
OnRatingChanged?.Invoke(StudentId, key, value); OnRatingChanged?.Invoke(StudentId, key, value);
} }
[RelayCommand]
private void ToggleHomework()
{
HomeworkMissing = !HomeworkMissing;
HomeworkChangedCallback?.Invoke(StudentId, HomeworkMissing);
}
[RelayCommand]
private void CycleAttendance()
{
Attendance = Attendance switch
{
null => AttendanceStatus.ExcusePending,
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
AttendanceStatus.Unexcused => null,
_ => null,
};
OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip));
AttendanceChangedCallback?.Invoke(StudentId, Attendance);
}
// Direktes Setzen (z.B. aus dem Grading-Wizard heraus), ohne den Zyklus zu durchlaufen.
public void SetAttendance(AttendanceStatus? value)
{
Attendance = value;
OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip));
AttendanceChangedCallback?.Invoke(StudentId, value);
}
}
// ── Anwesenheits-Anzeige ──────────────────────────────────────────────────────
public static class AttendanceDisplay
{
public static string Label(AttendanceStatus? s) => s switch
{
null => "Anwesend",
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
AttendanceStatus.Excused => "Krank, entschuldigt",
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
_ => "Anwesend",
};
public static string ShortLabel(AttendanceStatus? s) => s switch
{
null => "",
AttendanceStatus.ExcusePending => "K ?",
AttendanceStatus.Excused => "K ✓",
AttendanceStatus.Unexcused => "K ✗",
_ => "",
};
} }
// ── Eine Bewertungszelle ────────────────────────────────────────────────────── // ── Eine Bewertungszelle ──────────────────────────────────────────────────────
@@ -0,0 +1,427 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Grading-Wizard: Mitarbeit als Zeitleiste mit Abschnitten ────────────────
public partial class ParticipationWizardDialogViewModel : ObservableObject
{
private const string AbschnittPrefix = "Abschnitt: ";
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IParticipationSectionRepository _sectionRepo;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IGradeRepository _grades;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly string _schoolYear;
private readonly GradingSystem _gradingSystem;
private readonly List<Student> _students;
private readonly List<ParticipationSession> _allSessions;
private readonly List<ParticipationSection> _sectionList;
private readonly Dictionary<string, double> _aspectWeights;
public string GroupLabel { get; }
[ObservableProperty] private int _studentIndex;
[ObservableProperty] private string _studentName = "";
[ObservableProperty] private string _progressText = "";
[ObservableProperty] private string _newSectionLabel = "";
[ObservableProperty] private string _newSectionEndDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _sectionValidationMessage = "";
[ObservableProperty] private ParticipationPeriodOption _rollupPeriod;
[ObservableProperty] private string _rollupStatusMessage = "";
public List<ParticipationPeriodOption> RollupPeriodOptions { get; } =
[
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
];
public ObservableCollection<WizardSectionGroup> Timeline { get; } = [];
public ObservableCollection<WizardSectionRow> Sections { get; } = [];
public bool CanGoPrevious => StudentIndex > 0;
public bool CanGoNext => StudentIndex < _students.Count - 1;
private Guid CurrentStudentId => _students[StudentIndex].Id;
public ParticipationWizardDialogViewModel(
IParticipationSessionRepository sessions, IParticipationRepository entries,
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
IStudentRepository students, IExamRepository exams, IExamResultRepository results,
IGradeRepository grades, GradingService grading,
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
{
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
_exams = exams; _results = results; _grades = grades; _grading = grading;
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
GroupLabel = groupLabel;
_aspectWeights = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
.ToDictionary(a => a.Key, a => a.Weight);
_students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
_allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
_sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
_rollupPeriod = RollupPeriodOptions[0];
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
if (_students.Count > 0) ShowStudent(0);
}
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
private void PreviousStudent()
{
if (StudentIndex > 0) ShowStudent(StudentIndex - 1);
}
[RelayCommand(CanExecute = nameof(CanGoNext))]
private void NextStudent()
{
if (StudentIndex < _students.Count - 1) ShowStudent(StudentIndex + 1);
}
private void ShowStudent(int index)
{
StudentIndex = index;
StudentName = _students[index].FullName;
ProgressText = $"{index + 1} / {_students.Count}";
PreviousStudentCommand.NotifyCanExecuteChanged();
NextStudentCommand.NotifyCanExecuteChanged();
BuildTimeline(CurrentStudentId);
BuildSections(CurrentStudentId);
}
// ── Zeitleiste ────────────────────────────────────────────────────────────
private void BuildTimeline(Guid studentId)
{
var points = new List<(DateOnly Date, WizardTimelinePoint Point)>();
foreach (var session in _allSessions)
{
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
points.Add((session.Date, BuildSessionPoint(session, entry, studentId)));
}
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
{
var result = _results.GetByExamAndStudent(exam.Id, studentId);
if (result is null) continue;
points.Add((exam.Date, BuildExamPoint(exam, result)));
}
points = points.OrderBy(p => p.Date).ToList();
Timeline.Clear();
foreach (var section in _sectionList)
{
var group = new WizardSectionGroup($"{section.Label}\n{section.StartDate:dd.MM.}{section.EndDate:dd.MM.}", isOpen: false);
foreach (var (date, point) in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
group.Points.Add(point);
Timeline.Add(group);
}
var openStart = ComputeOpenStart();
var openGroup = new WizardSectionGroup($"läuft seit {openStart:dd.MM.}", isOpen: true);
foreach (var (date, point) in points.Where(p => p.Date >= openStart))
openGroup.Points.Add(point);
Timeline.Add(openGroup);
}
private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
{
var ratingLabel = entry is not null ? WeightedRatingLabel(entry) : "";
var note = entry?.Note;
var tooltip = $"{session.Date:dd.MM.yyyy}" +
(ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
(string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
{
HasHomework = entry?.HomeworkMissing ?? false,
AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
};
point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
return point;
}
private WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
{
var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
}
private string WeightedRatingLabel(ParticipationEntry entry)
{
if (entry.Ratings.Count == 0) return "";
var weightSum = 0.0; var valueSum = 0.0;
foreach (var r in entry.Ratings)
{
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w;
}
if (weightSum <= 0) return "";
return RatingLabel((int)Math.Round(valueSum / weightSum, MidpointRounding.AwayFromZero));
}
private static string RatingLabel(int v) => v switch
{
>= 2 => "++", 1 => "+", 0 => "~", -1 => "", _ => "−−",
};
private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.HomeworkMissing = !entry.HomeworkMissing;
_entries.Save(entry);
point.HasHomework = entry.HomeworkMissing;
}
private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Attendance = entry.Attendance switch
{
null => AttendanceStatus.ExcusePending,
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
AttendanceStatus.Unexcused => null,
_ => null,
};
_entries.Save(entry);
point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
}
// ── Abschnitte ────────────────────────────────────────────────────────────
private DateOnly ComputeOpenStart() =>
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
private void BuildSections(Guid studentId)
{
Sections.Clear();
var studentGrades = _grades.GetByStudentAndGroup(studentId, _groupId)
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
.ToList();
foreach (var section in _sectionList)
{
var grade = studentGrades.FirstOrDefault(g => g.Note == AbschnittPrefix + section.Label);
var row = new WizardSectionRow(section.Label, section.StartDate, section.EndDate,
grade?.Value ?? "", isOpen: false);
row.OnSave = SaveSectionGrade;
Sections.Add(row);
}
var openStart = ComputeOpenStart();
var suggestion = ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today));
Sections.Add(new WizardSectionRow("(läuft)", openStart, DateOnly.FromDateTime(DateTime.Today),
suggestion ?? "", isOpen: true));
}
private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
{
var points = new List<double>();
foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end))
{
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
if (entry is null || entry.Ratings.Count == 0) continue;
var weightSum = 0.0; var valueSum = 0.0;
foreach (var r in entry.Ratings)
{
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
valueSum += r.Value * w; weightSum += w;
}
if (weightSum > 0) points.Add(valueSum / weightSum);
}
return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
}
private void SaveSectionGrade(WizardSectionRow row)
{
if (string.IsNullOrWhiteSpace(row.Value)) { row.StatusMessage = "Wert darf nicht leer sein."; return; }
var noteTag = AbschnittPrefix + row.Label;
var record = _grades.GetByStudentAndGroup(CurrentStudentId, _groupId)
.FirstOrDefault(g => g.Category == GradeCategory.Participation && g.Note == noteTag)
?? new Grade
{
StudentId = CurrentStudentId,
GroupId = _groupId,
Category = GradeCategory.Participation,
Note = noteTag,
Date = row.EndDate,
};
record.Value = row.Value.Trim();
_grades.Save(record);
row.StatusMessage = "Gespeichert.";
}
[RelayCommand]
private void CloseSection()
{
if (string.IsNullOrWhiteSpace(NewSectionLabel)) { SectionValidationMessage = "Bezeichnung erforderlich."; return; }
if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null,
DateTimeStyles.None, out var end))
{ SectionValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
var start = ComputeOpenStart();
if (end < start) { SectionValidationMessage = "Enddatum liegt vor Abschnittsbeginn."; return; }
var section = new ParticipationSection { GroupId = _groupId, Label = NewSectionLabel.Trim(), StartDate = start, EndDate = end };
_sectionRepo.Save(section);
_sectionList.Add(section);
foreach (var student in _students)
{
var suggestion = ComputeSuggestion(student.Id, start, end);
if (suggestion is null) continue;
_grades.Save(new Grade
{
StudentId = student.Id,
GroupId = _groupId,
Category = GradeCategory.Participation,
Note = AbschnittPrefix + section.Label,
Value = suggestion,
Date = end,
Weight = 1.0,
});
}
SectionValidationMessage = "";
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
BuildTimeline(CurrentStudentId);
BuildSections(CurrentStudentId);
}
// ── Halbjahresnote aus Abschnitten ────────────────────────────────────────
[RelayCommand]
private void ApplyRollup()
{
var periodTag = $"Mitarbeit {RollupPeriod.Label} {_schoolYear}";
var applied = 0;
foreach (var student in _students)
{
var sectionGrades = _grades.GetByStudentAndGroup(student.Id, _groupId)
.Where(g => g.Category == GradeCategory.Participation && g.Note is not null && g.Note.StartsWith(AbschnittPrefix))
.Where(g => InPeriod(g.Date, RollupPeriod.Period))
.Select(g => (g.Value, g.Weight))
.ToList();
if (sectionGrades.Count == 0) continue;
var average = _grading.WeightedAverage(sectionGrades);
var rounded = _grading.RoundToGrade(average, _gradingSystem, RoundingRule.Commercial);
var record = _grades.GetByStudentAndGroup(student.Id, _groupId)
.FirstOrDefault(g => g.Category == GradeCategory.Participation && g.Note == periodTag)
?? new Grade { StudentId = student.Id, GroupId = _groupId, Category = GradeCategory.Participation, Note = periodTag };
record.Value = rounded;
record.Date = DateOnly.FromDateTime(DateTime.Today);
_grades.Save(record);
applied++;
}
RollupStatusMessage = applied == 0
? "Keine Abschnittsnoten im gewählten Zeitraum."
: $"{applied} Halbjahresnote(n) aus Abschnitten übernommen.";
}
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
{
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
}
// ── Zeitleisten-Bausteine ────────────────────────────────────────────────────
public class WizardSectionGroup(string bandLabel, bool isOpen)
{
public string BandLabel { get; } = bandLabel;
public bool IsOpen { get; } = isOpen;
public ObservableCollection<WizardTimelinePoint> Points { get; } = [];
}
public partial class WizardTimelinePoint : ObservableObject
{
public string DateDisplay { get; }
public bool IsExam { get; }
public string RatingLabel { get; }
public string ExamLabel { get; }
public bool HasNote { get; }
public string TooltipText { get; }
[ObservableProperty] private bool _hasHomework;
[ObservableProperty] private string _attendanceIcon = "";
[ObservableProperty] private string _attendanceTooltip = "Anwesend";
public bool IsAbsent => AttendanceIcon.Length > 0;
public string AttendanceButtonLabel => AttendanceIcon.Length > 0 ? AttendanceIcon : "Anw";
partial void OnAttendanceIconChanged(string value)
{
OnPropertyChanged(nameof(IsAbsent));
OnPropertyChanged(nameof(AttendanceButtonLabel));
}
public IRelayCommand? ToggleHomeworkCommand { get; set; }
public IRelayCommand? CycleAttendanceCommand { get; set; }
public WizardTimelinePoint(DateOnly date, bool isExam, string ratingLabel, string examLabel,
bool hasNote, string tooltip)
{
DateDisplay = date.ToString("dd.MM.", CultureInfo.InvariantCulture);
IsExam = isExam;
RatingLabel = ratingLabel;
ExamLabel = examLabel;
HasNote = hasNote;
TooltipText = tooltip;
}
}
// ── Abschnittsnote-Zeile ─────────────────────────────────────────────────────
public partial class WizardSectionRow : ObservableObject
{
public string Label { get; }
public string RangeDisplay { get; }
public DateOnly EndDate { get; }
public bool IsOpen { get; }
[ObservableProperty] private string _value;
[ObservableProperty] private string _statusMessage = "";
public Action<WizardSectionRow>? OnSave { get; set; }
public WizardSectionRow(string label, DateOnly start, DateOnly end, string value, bool isOpen)
{
Label = label;
RangeDisplay = $"{start:dd.MM.yyyy} {end:dd.MM.yyyy}";
EndDate = end;
IsOpen = isOpen;
_value = value;
}
[RelayCommand]
private void Save() => OnSave?.Invoke(this);
}
@@ -0,0 +1,254 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Zeugnisnote (2.4) ─────────────────────────────────────────────────────────
public partial class ReportGradeDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IGradingSchemeRepository _schemes;
private readonly IReportGradeRepository _reportGrades;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly GroupType _groupType;
private readonly GradingSystem _gradingSystem;
private readonly string _groupLabel;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
[ObservableProperty] private string _schemeSummary = "";
public string GroupLabel => _groupLabel;
public List<ParticipationPeriodOption> PeriodOptions { get; } =
[
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
];
public string[] RoundingOptions { get; } = RoundingRuleDisplay.Options;
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string RoundingRuleName
{
get => RoundingRuleDisplay.Label(RoundingRule);
set => RoundingRule = RoundingRuleDisplay.FromLabel(value);
}
public ObservableCollection<ReportGradeRow> Rows { get; } = [];
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel)
{
_grades = grades; _exams = exams; _results = results; _students = students;
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
_selectedPeriod = PeriodOptions[0];
Recompute();
}
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
partial void OnRoundingRuleChanged(RoundingRule value) => Recompute();
private GradingScheme ResolveScheme() =>
_schemes.GetByGroup(_groupId)
?? _schemes.GetDefaultForType(_groupType)
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
private void Recompute()
{
var scheme = ResolveScheme();
SchemeSummary = $"Klausuren {scheme.ExamsPercent:0.#} % · Mitarbeit {scheme.ParticipationPercent:0.#} % · " +
$"Sonstige {scheme.OtherPercent:0.#} %";
var period = SelectedPeriod.Period;
var periodTag = SelectedPeriod.Label;
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
var exams = _exams.GetByGroup(_groupId).Where(e => InPeriod(e.Date, period)).ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var allGrades = _grades.GetByGroup(_groupId).Where(g => InPeriod(g.Date, period)).ToList();
Rows.Clear();
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (!StudentActiveInPeriod(membership, period)) continue;
var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
if (existing is { IsLocked: true })
{
Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, Save, ToggleLock));
continue;
}
var examGrades = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
if (result.Absent || result.Grade is null) continue;
examGrades.Add((result.Grade, 1.0));
}
var participationGrades = allGrades
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation)
.Select(g => (g.Value, g.Weight)).ToList();
var otherGrades = allGrades
.Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation)
.Select(g => (g.Value, g.Weight)).ToList();
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
scheme, _gradingSystem, RoundingRule);
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, Save, ToggleLock));
}
}
private void Save(ReportGradeRow row)
{
if (!string.IsNullOrWhiteSpace(row.OverrideValue) && string.IsNullOrWhiteSpace(row.OverrideReason))
{
row.ValidationMessage = "Für ein manuelles Übersteuern ist eine Begründung Pflicht.";
return;
}
row.ValidationMessage = "";
var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label)
?? new ReportGrade { StudentId = row.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label };
record.CalculatedValue = row.CalculatedValue ?? "";
record.OverrideValue = string.IsNullOrWhiteSpace(row.OverrideValue) ? null : row.OverrideValue.Trim();
record.OverrideReason = string.IsNullOrWhiteSpace(row.OverrideReason) ? null : row.OverrideReason.Trim();
_reportGrades.Save(record);
row.MarkSaved();
}
private void ToggleLock(ReportGradeRow row)
{
var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label);
if (record is null)
{
if (!row.IsLocked)
{
// Festschreiben ohne vorherigen Save: aktuellen Stand zuerst sichern.
Save(row);
record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label);
if (record is null) return;
}
else return;
}
record.IsLocked = !record.IsLocked;
_reportGrades.Save(record);
Recompute();
}
public string ExportCsv()
{
var sb = new StringBuilder();
sb.AppendLine($"Zeugnisnoten;{_groupLabel};{SelectedPeriod.Label}");
sb.AppendLine("Schüler;Berechnet;Übersteuert;Begründung;Endnote;Gesperrt");
foreach (var r in Rows)
sb.AppendLine($"{r.Name};{r.CalculatedValue};{r.OverrideValue};{r.OverrideReason};{r.FinalDisplay};{(r.IsLocked ? "ja" : "")}");
return sb.ToString();
}
private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch
{
ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1,
ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7,
_ => true,
};
private static bool StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
{
if (period == ParticipationPeriod.FullYear || m is null) return true;
return m.Period switch
{
MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
_ => true,
};
}
}
// ── Rundungsregel-Anzeige ─────────────────────────────────────────────────────
public static class RoundingRuleDisplay
{
public static string Label(RoundingRule r) => r switch
{
RoundingRule.Commercial => "Kaufmännisch",
RoundingRule.Pedagogical => "Pädagogisch",
_ => r.ToString(),
};
public static string[] Options { get; } = [Label(RoundingRule.Commercial), Label(RoundingRule.Pedagogical)];
public static RoundingRule FromLabel(string? label) =>
label == Label(RoundingRule.Pedagogical) ? RoundingRule.Pedagogical : RoundingRule.Commercial;
}
// ── Zeile: Zeugnisnote eines Schülers ────────────────────────────────────────
public partial class ReportGradeRow : ObservableObject
{
public Guid StudentId { get; }
public string Name { get; }
public string? CalculatedValue { get; private set; }
public bool IsLocked { get; private set; }
[ObservableProperty] private string? _overrideValue;
[ObservableProperty] private string? _overrideReason;
[ObservableProperty] private string _validationMessage = "";
public string CalculatedDisplay => CalculatedValue ?? "";
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay;
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
public IRelayCommand SaveCommand { get; }
public IRelayCommand ToggleLockCommand { get; }
private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing,
bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
{
StudentId = studentId;
Name = name;
CalculatedValue = calculated;
IsLocked = locked;
_overrideValue = existing?.OverrideValue;
_overrideReason = existing?.OverrideReason;
SaveCommand = new RelayCommand(() => onSave(this));
ToggleLockCommand = new RelayCommand(() => onToggleLock(this));
}
public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated,
ReportGrade? existing, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, calculated, existing, existing?.IsLocked ?? false, onSave, onToggleLock);
public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked,
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, locked.CalculatedValue, locked, true, onSave, onToggleLock);
public void MarkSaved()
{
OnPropertyChanged(nameof(FinalDisplay));
}
partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay));
}
@@ -16,6 +16,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISubjectRepository _subjects; private readonly ISubjectRepository _subjects;
private readonly ICompetencyDomainRepository _domainRepo; private readonly ICompetencyDomainRepository _domainRepo;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates; private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly IGradingSchemeRepository _gradingSchemes;
private readonly GradingService _grading; private readonly GradingService _grading;
// ── Fächer ──────────────────────────────────────────────────────────────── // ── Fächer ────────────────────────────────────────────────────────────────
@@ -45,17 +46,39 @@ public partial class SettingsViewModel : ObservableObject
public List<string> GradingSystemOptions { get; } = ["Noten 16", "Punkte 015"]; public List<string> GradingSystemOptions { get; } = ["Noten 16", "Punkte 015"];
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = []; public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading) IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates; _gradingKeyTemplates = gradingKeyTemplates;
_gradingSchemes = gradingSchemes;
_grading = grading; _grading = grading;
LoadSubjects(); LoadSubjects();
LoadGradingKeyTemplates(); LoadGradingKeyTemplates();
LoadGradingSchemes();
}
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
private void LoadGradingSchemes()
{
ClassScheme = new GradingSchemeEditItem(
_gradingSchemes.GetDefaultForType(GroupType.Class)
?? new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 },
"Klassen", _gradingSchemes, _grading);
CourseScheme = new GradingSchemeEditItem(
_gradingSchemes.GetDefaultForType(GroupType.Course)
?? new GradingScheme { GroupType = GroupType.Course, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 },
"Kurse", _gradingSchemes, _grading);
} }
// ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ──────────────── // ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ────────────────
@@ -407,6 +430,47 @@ public class GradingKeyEntryVm
} }
} }
// ── GradingSchemeEditItem (2.3) ────────────────────────────────────────────────
public partial class GradingSchemeEditItem : ObservableObject
{
private readonly GradingScheme _scheme;
private readonly IGradingSchemeRepository _repo;
private readonly GradingService _grading;
public string Label { get; }
[ObservableProperty] private double _examsPercent;
[ObservableProperty] private double _participationPercent;
[ObservableProperty] private double _otherPercent;
[ObservableProperty] private string _validationMessage = "";
[ObservableProperty] private string _statusMessage = "";
public GradingSchemeEditItem(GradingScheme scheme, string label, IGradingSchemeRepository repo, GradingService grading)
{
_scheme = scheme; _repo = repo; _grading = grading;
Label = label;
_examsPercent = scheme.ExamsPercent;
_participationPercent = scheme.ParticipationPercent;
_otherPercent = scheme.OtherPercent;
}
[RelayCommand]
private void Save()
{
_scheme.ExamsPercent = ExamsPercent;
_scheme.ParticipationPercent = ParticipationPercent;
_scheme.OtherPercent = OtherPercent;
var error = _grading.ValidateGradingScheme(_scheme);
if (error is not null) { ValidationMessage = error; StatusMessage = ""; return; }
ValidationMessage = "";
_repo.Save(_scheme);
StatusMessage = "Gespeichert.";
}
}
// ── Hilfklassen ─────────────────────────────────────────────────────────────── // ── Hilfklassen ───────────────────────────────────────────────────────────────
public class SubjectListItem(Subject s) public class SubjectListItem(Subject s)
@@ -2,7 +2,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students; namespace LehrerApp.Desktop.ViewModels.Students;
@@ -72,6 +74,9 @@ public partial class StudentDetailViewModel : ObservableObject
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects; private readonly ISubjectRepository _subjects;
private readonly IDocumentationRepository _docs; private readonly IDocumentationRepository _docs;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IGradeRepository _grades;
[ObservableProperty] private Student? _student; [ObservableProperty] private Student? _student;
[ObservableProperty] private string _studentTitle = ""; [ObservableProperty] private string _studentTitle = "";
@@ -83,16 +88,19 @@ public partial class StudentDetailViewModel : ObservableObject
public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = []; public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = []; public ObservableCollection<DocEntry> Documentation { get; } = [];
public ObservableCollection<ContactItem> Contacts { get; } = []; public ObservableCollection<ContactItem> Contacts { get; } = [];
public ObservableCollection<StudentGradeHistoryGroup> GradeHistory { get; } = [];
public bool HasNoContacts => Contacts.Count == 0; public bool HasNoContacts => Contacts.Count == 0;
public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; } public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; }
public Action<ContactItem>? OnViewAddress { get; set; } public Action<ContactItem>? OnViewAddress { get; set; }
public StudentDetailViewModel(IStudentRepository students, public StudentDetailViewModel(IStudentRepository students,
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
IDocumentationRepository docs) IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults,
IGradeRepository grades)
{ {
_students = students; _memberships = memberships; _students = students; _memberships = memberships;
_groups = groups; _subjects = subjects; _docs = docs; _groups = groups; _subjects = subjects; _docs = docs;
_exams = exams; _examResults = examResults; _grades = grades;
} }
public void LoadStudent(Guid id) public void LoadStudent(Guid id)
@@ -104,12 +112,16 @@ public partial class StudentDetailViewModel : ObservableObject
EditLastName = Student.LastName; EditLastName = Student.LastName;
GroupMemberships.Clear(); GroupMemberships.Clear();
GradeHistory.Clear();
foreach (var membership in _memberships.GetByStudent(Student.Id)) foreach (var membership in _memberships.GetByStudent(Student.Id))
{ {
var g = _groups.GetById(membership.GroupId); var g = _groups.GetById(membership.GroupId);
if (g is null) continue; if (g is null) continue;
var subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : ""; var subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "";
GroupMemberships.Add(new() { SchoolYear = g.SchoolYear, GroupName = g.Name, Subject = subject }); GroupMemberships.Add(new() { SchoolYear = g.SchoolYear, GroupName = g.Name, Subject = subject });
var historyGroup = BuildGradeHistory(g, subject);
if (historyGroup is not null) GradeHistory.Add(historyGroup);
} }
LoadContacts(); LoadContacts();
@@ -128,6 +140,49 @@ public partial class StudentDetailViewModel : ObservableObject
IsConfidential = d.IsConfidential }); IsConfidential = d.IsConfidential });
} }
// ── Notenentwicklung (2.5) ────────────────────────────────────────────────
private StudentGradeHistoryGroup? BuildGradeHistory(LearningGroup g, string subject)
{
var label = string.IsNullOrEmpty(subject) ? g.Name : $"{g.Name} · {subject}";
var entries = new List<(DateOnly Date, string PointLabel, string Value)>();
foreach (var exam in _exams.GetByGroup(g.Id))
{
var result = _examResults.GetByExamAndStudent(exam.Id, Student!.Id);
if (result is null || result.Absent || result.Grade is null) continue;
entries.Add((exam.Date, exam.Title, result.Grade));
}
foreach (var grade in _grades.GetByStudentAndGroup(Student!.Id, g.Id))
entries.Add((grade.Date, GradeCategoryDisplay.Label(grade.Category), grade.Value));
var ordered = entries.OrderBy(e => e.Date).ToList();
if (ordered.Count == 0) return null;
var historyGroup = new StudentGradeHistoryGroup(label);
int? previousNoteEquivalent = null;
foreach (var e in ordered)
{
int? noteEquivalent = int.TryParse(e.Value, out var raw)
? (g.GradingSystem == GradingSystem.Grades1To6 ? raw : int.Parse(PointsNoteMapping.PointsToNote(raw)))
: null;
var isFailing = noteEquivalent is >= 5;
var isDrop = noteEquivalent.HasValue && previousNoteEquivalent.HasValue
&& noteEquivalent.Value - previousNoteEquivalent.Value >= 1;
var warnings = new List<string>();
if (isDrop) warnings.Add("Abfall um ≥ 1 Note");
if (isFailing) warnings.Add("Versetzungsgefährdung");
historyGroup.Points.Add(new GradeHistoryPoint(e.Date, e.PointLabel, e.Value,
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
}
return historyGroup;
}
[RelayCommand] private void StartEdit() => IsEditing = true; [RelayCommand] private void StartEdit() => IsEditing = true;
[RelayCommand] private void CancelEdit() [RelayCommand] private void CancelEdit()
{ {
@@ -200,6 +255,43 @@ public partial class StudentDetailViewModel : ObservableObject
public class GroupMembershipEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; } public class GroupMembershipEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; }
public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } } public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } }
// ── Notenentwicklung (2.5) ──────────────────────────────────────────────────
public class StudentGradeHistoryGroup(string label)
{
public string Label { get; } = label;
public ObservableCollection<GradeHistoryPoint> Points { get; } = [];
public bool HasWarnings => Points.Any(p => p.IsWarning);
}
public class GradeHistoryPoint
{
public string DateDisplay { get; }
public string Label { get; }
public string Value { get; }
public double BarHeight { get; }
public bool IsWarning { get; }
public string WarningText { get; }
public string TooltipText { get; }
public GradeHistoryPoint(DateOnly date, string label, string value, int? noteEquivalent,
bool isWarning, string warningText)
{
DateDisplay = date.ToString("dd.MM.", CultureInfo.InvariantCulture);
Label = label;
Value = value;
IsWarning = isWarning;
WarningText = warningText;
// Balkenhöhe nach Notenqualität (1 = beste Note) auf 6..60px, sonst neutrale Mindesthöhe.
BarHeight = noteEquivalent.HasValue
? 6 + Math.Clamp((6 - noteEquivalent.Value) / 5.0, 0, 1) * 54
: 6;
TooltipText = warningText.Length > 0
? $"{date:dd.MM.yyyy} · {label}: {value} ⚠ {warningText}"
: $"{date:dd.MM.yyyy} · {label}: {value}";
}
}
public class ContactItem public class ContactItem
{ {
public Contact Model { get; } public Contact Model { get; }
@@ -167,6 +167,39 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Offene Entschuldigungen: neben dem Kalender, ebenfalls feste Position -->
<Border Grid.Column="1" Grid.Row="1" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="OFFENE ENTSCHULDIGUNGEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenExcuses}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:OpenExcuseItem">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock FontSize="11" Opacity="0.6">
<Run Text="{Binding GroupName}"/>
<Run Text=" · "/>
<Run Text="{Binding DateDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Entschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkExcusedCommand}" Margin="0,0,4,0"/>
<Button Grid.Column="2" Content="Unentschuldigt" FontSize="11" Padding="7,3"
Command="{Binding MarkUnexcusedCommand}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Entschuldigungen." Opacity="0.4" FontSize="13"
IsVisible="{Binding !OpenExcuses.Count}"/>
</StackPanel>
</Border>
<!-- Meine Lerngruppen: wächst mit der Zeit, deshalb ganz unten und volle Breite --> <!-- Meine Lerngruppen: wächst mit der Zeit, deshalb ganz unten und volle Breite -->
<Border Grid.Column="0" Grid.Row="2" Grid.ColumnSpan="2" <Border Grid.Column="0" Grid.Row="2" Grid.ColumnSpan="2"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
@@ -0,0 +1,51 @@
<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.CollectiveGradeDialog"
x:DataType="vm:CollectiveGradeDialogViewModel"
Title="Sammelnote erfassen"
Width="480" Height="640" MinWidth="400" MinHeight="360"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="Sammelnote erfassen" FontSize="18" FontWeight="SemiBold"/>
<StackPanel Grid.Row="1" Spacing="6" Margin="0,12,0,10">
<Grid ColumnDefinitions="120,*,90" ColumnSpacing="6">
<ComboBox Grid.Column="0" ItemsSource="{x:Static vm:GradeCategoryDisplay.Options}"
SelectedItem="{Binding CategoryName}"/>
<TextBox Grid.Column="1" Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
<NumericUpDown Grid.Column="2" Value="{Binding Weight}" Minimum="0" Maximum="10"
Increment="0.1" FormatString="0.#" ShowButtonSpinner="False"
ToolTip.Tip="Gewichtung"/>
</Grid>
<TextBox Text="{Binding Note}" PlaceholderText="Notiz (z.B. Hausaufgabenkontrolle)"/>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="Werte pro Schüler unten eintragen. Leere Felder werden nicht gespeichert."
FontSize="11" Opacity="0.5" TextWrapping="Wrap"/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CollectiveGradeStudentRow">
<Grid ColumnDefinitions="*,100" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" FontSize="13"/>
<TextBox Grid.Column="1" Text="{Binding Value}" PlaceholderText="Wert"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" Margin="0,16,0,0">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveAllCommand}" Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fertig" Click="OnClose"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class CollectiveGradeDialog : Window
{
public CollectiveGradeDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,40 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GradeOverviewTabView"
x:DataType="vm:GradeOverviewTabViewModel">
<Grid RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Zeitraum:" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding PeriodOptions}" SelectedItem="{Binding SelectedPeriod}" MinWidth="170"/>
<ToggleButton Content="Punkte anzeigen" IsChecked="{Binding ShowAsPoints}"
IsVisible="{Binding CanTogglePointsView}" Margin="12,0,0,0"/>
<Button Content="Sortierung: Name" Command="{Binding SortByNameCommand}" Margin="12,0,0,0"/>
<Button Content="Sortierung: Gesamt" Command="{Binding SortByTotalCommand}"/>
<Button Content="Noten verwalten" Command="{Binding ManageStudentGradesCommand}" Margin="12,0,0,0"
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Content=" Sammelnote" Command="{Binding CollectiveGradeCommand}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}"/>
</StackPanel>
<DataGrid Grid.Row="1"
x:Name="GradesGrid"
ItemsSource="{Binding Rows}"
SelectedItem="{Binding SelectedRow}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="All"
CanUserReorderColumns="False"
CanUserResizeColumns="True"/>
<TextBlock Grid.Row="1" Text="Keine Schüler in dieser Gruppe."
HorizontalAlignment="Center" VerticalAlignment="Center"
Opacity="0.35" FontSize="14"
IsVisible="{Binding !Rows.Count}"/>
</Grid>
</UserControl>
@@ -0,0 +1,107 @@
using Avalonia.Controls;
using Avalonia.Data;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GradeOverviewTabView : UserControl
{
private GradeOverviewTabViewModel? _vm;
public GradeOverviewTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is GradeOverviewTabViewModel vm)
{
_vm = vm;
vm.OnManageStudentGrades = ShowStudentGradesDialog;
vm.OnCollectiveGrade = ShowCollectiveGradeDialog;
vm.OnReportGrades = ShowReportGradesDialog;
vm.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(GradeOverviewTabViewModel.RebuildColumnsSignal))
BuildColumns();
};
BuildColumns();
}
}
private async Task ShowStudentGradesDialog(GradeOverviewRow row)
{
var dialogVm = new StudentGradesDialogViewModel(
App.Services.GetRequiredService<IGradeRepository>(),
row.StudentId, _vm!.GroupId, row.Name);
var dialog = new StudentGradesDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private async Task ShowCollectiveGradeDialog()
{
var dialogVm = new CollectiveGradeDialogViewModel(
App.Services.GetRequiredService<IGradeRepository>(),
App.Services.GetRequiredService<IStudentRepository>(),
_vm!.GroupId);
var dialog = new CollectiveGradeDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private async Task ShowReportGradesDialog()
{
var dialogVm = new ReportGradeDialogViewModel(
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<GradingService>(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel);
var dialog = new ReportGradeDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private void BuildColumns()
{
var grid = this.FindControl<DataGrid>("GradesGrid");
if (grid is null || _vm is null) return;
grid.Columns.Clear();
grid.Columns.Add(new DataGridTextColumn
{
Header = "Schüler",
Binding = new Binding("Name"),
Width = new DataGridLength(160, DataGridLengthUnitType.Pixel),
});
foreach (var (col, i) in _vm.Columns.Select((c, i) => (c, i)))
{
var idx = i;
grid.Columns.Add(new DataGridTextColumn
{
Header = col.Header,
Binding = new Binding($"Cells[{idx}]"),
Width = new DataGridLength(120, DataGridLengthUnitType.Pixel),
});
}
grid.Columns.Add(new DataGridTextColumn
{
Header = "Gesamt",
Binding = new Binding("TotalDisplay"),
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
});
}
}
@@ -151,12 +151,7 @@
<!-- Tab: Noten --> <!-- Tab: Noten -->
<ContentPage Header="Noten"> <ContentPage Header="Noten">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> <views:GradeOverviewTabView DataContext="{Binding GradeOverviewTab}"/>
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage> </ContentPage>
<!-- Tab: Planung --> <!-- Tab: Planung -->
@@ -48,7 +48,7 @@
<DataGridTextColumn Header="Schüler" Binding="{Binding Name}" Width="2*"/> <DataGridTextColumn Header="Schüler" Binding="{Binding Name}" Width="2*"/>
<DataGridTextColumn Header="Sitzungen" Binding="{Binding SessionCount}" Width="Auto"/> <DataGridTextColumn Header="Sitzungen" Binding="{Binding SessionCount}" Width="Auto"/>
<DataGridTextColumn Header="Ø Bewertung" Binding="{Binding AverageDisplay}" Width="Auto"/> <DataGridTextColumn Header="Ø Bewertung" Binding="{Binding AverageDisplay}" Width="Auto"/>
<DataGridTextColumn Header="Trend" Binding="{Binding TrendSymbol}" Width="Auto"/> <DataGridTextColumn Header="Entwicklung" Binding="{Binding TrendSymbol}" Width="Auto"/>
<DataGridTextColumn Header="Note" Binding="{Binding GradeDisplay}" Width="Auto"/> <DataGridTextColumn Header="Note" Binding="{Binding GradeDisplay}" Width="Auto"/>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
@@ -10,7 +10,7 @@
<Border Grid.Column="0" <Border Grid.Column="0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0"> BorderThickness="0,0,1,0">
<Grid RowDefinitions="Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,Auto,*">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="6" Margin="10,10,10,6"> <StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="6" Margin="10,10,10,6">
<Button Content=" Sitzung" Command="{Binding AddSessionCommand}" HorizontalAlignment="Stretch"/> <Button Content=" Sitzung" Command="{Binding AddSessionCommand}" HorizontalAlignment="Stretch"/>
@@ -20,7 +20,10 @@
<Button Grid.Row="1" Content="Ø Mitarbeitsnote" Command="{Binding ComputeGradeCommand}" <Button Grid.Row="1" Content="Ø Mitarbeitsnote" Command="{Binding ComputeGradeCommand}"
HorizontalAlignment="Stretch" Margin="10,0,10,6"/> HorizontalAlignment="Stretch" Margin="10,0,10,6"/>
<ListBox Grid.Row="2" <Button Grid.Row="2" Content="Mitarbeits-Assistent" Command="{Binding OpenWizardCommand}"
HorizontalAlignment="Stretch" Margin="10,0,10,6"/>
<ListBox Grid.Row="3"
ItemsSource="{Binding Sessions}" ItemsSource="{Binding Sessions}"
SelectedItem="{Binding SelectedSession}" SelectedItem="{Binding SelectedSession}"
BorderThickness="0"> BorderThickness="0">
@@ -48,7 +51,7 @@
<ToggleButton Content="◇ Kompetenzen" <ToggleButton Content="◇ Kompetenzen"
IsChecked="{Binding CompetencyTagsVisible}" IsChecked="{Binding CompetencyTagsVisible}"
FontSize="11" Padding="8,3"/> FontSize="11" Padding="8,3"/>
<ToggleButton Content="◈ Schüler-Ratings" <ToggleButton Content="◈ Schüler-Bewertungen"
IsChecked="{Binding StudentCompetencyRatingsVisible}" IsChecked="{Binding StudentCompetencyRatingsVisible}"
FontSize="11" Padding="8,3"/> FontSize="11" Padding="8,3"/>
</StackPanel> </StackPanel>
@@ -24,6 +24,7 @@ public partial class ParticipationTabView : UserControl
vm.OnAddSession = ShowAddSessionDialog; vm.OnAddSession = ShowAddSessionDialog;
vm.OnQuickInput = ShowQuickInputDialog; vm.OnQuickInput = ShowQuickInputDialog;
vm.OnComputeGrade = ShowComputeGradeDialog; vm.OnComputeGrade = ShowComputeGradeDialog;
vm.OnOpenWizard = ShowWizardDialog;
vm.Aspects.CollectionChanged += (_, _) => BuildColumns(); vm.Aspects.CollectionChanged += (_, _) => BuildColumns();
vm.PropertyChanged += (_, pe) => vm.PropertyChanged += (_, pe) =>
{ {
@@ -60,6 +61,19 @@ public partial class ParticipationTabView : UserControl
}); });
} }
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "HA",
Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
CellTemplate = BuildHomeworkCellTemplate(),
});
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Anwesenheit",
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
CellTemplate = BuildAttendanceCellTemplate(),
});
// Kompetenz-Spalten (opt-in) // Kompetenz-Spalten (opt-in)
if (_vm.StudentCompetencyRatingsVisible && _vm.ActiveCompetencyCodes.Count > 0) if (_vm.StudentCompetencyRatingsVisible && _vm.ActiveCompetencyCodes.Count > 0)
{ {
@@ -119,6 +133,66 @@ public partial class ParticipationTabView : UserControl
}); });
} }
private static IDataTemplate BuildHomeworkCellTemplate()
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
if (row is null) return new TextBlock();
var btn = new Button
{
Content = "HA vergessen",
FontSize = 10,
Padding = new Avalonia.Thickness(5, 1),
Opacity = row.HomeworkMissing ? 1.0 : 0.25,
Command = row.ToggleHomeworkCommand,
};
ToolTip.SetTip(btn, "Hausaufgaben vergessen (an/aus)");
row.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(ParticipationStudentRow.HomeworkMissing))
btn.Opacity = row.HomeworkMissing ? 1.0 : 0.25;
};
return new StackPanel
{
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Children = { btn },
};
});
}
private static IDataTemplate BuildAttendanceCellTemplate()
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
if (row is null) return new TextBlock();
var btn = new Button
{
Content = string.IsNullOrEmpty(row.AttendanceLabel) ? "anwesend" : row.AttendanceLabel,
FontSize = 10,
Padding = new Avalonia.Thickness(5, 1),
Command = row.CycleAttendanceCommand,
};
ToolTip.SetTip(btn, row.AttendanceTooltip);
row.PropertyChanged += (_, pe) =>
{
if (pe.PropertyName == nameof(ParticipationStudentRow.AttendanceLabel))
{
btn.Content = string.IsNullOrEmpty(row.AttendanceLabel) ? "anwesend" : row.AttendanceLabel;
ToolTip.SetTip(btn, row.AttendanceTooltip);
}
};
return new StackPanel
{
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Children = { btn },
};
});
}
private static string AspectShortcut(int i) => i switch private static string AspectShortcut(int i) => i switch
{ {
0 => "Q", 1 => "W", 2 => "E", 3 => "R", 4 => "T", _ => "" 0 => "Q", 1 => "W", 2 => "E", 3 => "R", 4 => "T", _ => ""
@@ -163,4 +237,24 @@ public partial class ParticipationTabView : UserControl
if (owner is not null) if (owner is not null)
await dialog.ShowDialog(owner); await dialog.ShowDialog(owner);
} }
private async Task ShowWizardDialog(ParticipationTabViewModel tabVm)
{
var dialogVm = new ParticipationWizardDialogViewModel(
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<IParticipationAspectRepository>(),
App.Services.GetRequiredService<IParticipationSectionRepository>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IExamRepository>(),
App.Services.GetRequiredService<IExamResultRepository>(),
App.Services.GetRequiredService<IGradeRepository>(),
App.Services.GetRequiredService<GradingService>(),
tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem, tabVm.GroupLabel);
var dialog = new ParticipationWizardDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null)
await dialog.ShowDialog(owner);
}
} }
@@ -0,0 +1,160 @@
<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.ParticipationWizardDialog"
x:DataType="vm:ParticipationWizardDialogViewModel"
Title="Mitarbeits-Assistent"
Width="920" Height="760" MinWidth="720" MinHeight="520"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding GroupLabel}" FontSize="13" Opacity="0.5"/>
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StudentName}" FontSize="18" FontWeight="SemiBold"/>
<StackPanel Grid.Column="2" 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>
</StackPanel>
<TextBlock Grid.Row="1" Text="Zeitleiste" FontSize="13" FontWeight="SemiBold" Margin="0,14,0,6"/>
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Timeline}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="10"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Styles>
<Style Selector="Border.sectionband">
<Setter Property="Background" Value="#E1F5EE"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<Style Selector="Border.sectionband.open">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="1.5"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
</Style>
<Style Selector="Button.haicon">
<Setter Property="Opacity" Value="0.2"/>
</Style>
<Style Selector="Button.haicon.active">
<Setter Property="Opacity" Value="1"/>
<Setter Property="Background" Value="#E24B4A"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Button.attendanceicon">
<Setter Property="Opacity" Value="0.2"/>
</Style>
<Style Selector="Button.attendanceicon.active">
<Setter Property="Opacity" Value="1"/>
<Setter Property="Background" Value="#EF9F27"/>
<Setter Property="Foreground" Value="White"/>
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WizardSectionGroup">
<Border Classes="sectionband" Classes.open="{Binding IsOpen}" CornerRadius="6" Padding="8,8" VerticalAlignment="Top">
<StackPanel Spacing="6">
<TextBlock Text="{Binding BandLabel}" FontSize="10" Opacity="0.6" TextAlignment="Center"/>
<ItemsControl ItemsSource="{Binding Points}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WizardTimelinePoint">
<StackPanel Width="54" ToolTip.Tip="{Binding TooltipText}">
<TextBlock Text="{Binding DateDisplay}" FontSize="9" Opacity="0.5" HorizontalAlignment="Center"/>
<Border IsVisible="{Binding !IsExam}" Background="{DynamicResource SystemControlBackgroundAccentBrush}"
CornerRadius="3" Padding="4,1" HorizontalAlignment="Center" Margin="0,2">
<TextBlock Text="{Binding RatingLabel}" FontSize="11" Foreground="White" HorizontalAlignment="Center"/>
</Border>
<Border IsVisible="{Binding IsExam}" Background="#7F77DD"
CornerRadius="3" Padding="3,1" HorizontalAlignment="Center" Margin="0,2">
<TextBlock Text="{Binding ExamLabel}" FontSize="9" Foreground="White"
HorizontalAlignment="Center" TextWrapping="Wrap" TextAlignment="Center"/>
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="2"
IsVisible="{Binding !IsExam}">
<Button Classes="haicon" Classes.active="{Binding HasHomework}"
Content="HA" FontSize="8" Padding="3,0" Command="{Binding ToggleHomeworkCommand}"
ToolTip.Tip="Hausaufgaben vergessen (an/aus)"/>
<Button Classes="attendanceicon" Classes.active="{Binding IsAbsent}"
Content="{Binding AttendanceButtonLabel}" FontSize="8" Padding="3,0"
Command="{Binding CycleAttendanceCommand}"
ToolTip.Tip="{Binding AttendanceTooltip}"/>
<TextBlock Text="💬" FontSize="9" IsVisible="{Binding HasNote}" ToolTip.Tip="Bemerkung"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<StackPanel Grid.Row="3" Spacing="4" Margin="0,14,0,0">
<TextBlock Text="Abschnittsnoten dieses Schülers" FontSize="13" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding Sections}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WizardSectionRow">
<Grid ColumnDefinitions="140,170,100,Auto,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Label}" VerticalAlignment="Center" FontSize="12"/>
<TextBlock Grid.Column="1" Text="{Binding RangeDisplay}" VerticalAlignment="Center" FontSize="11" Opacity="0.6"/>
<TextBox Grid.Column="2" Text="{Binding Value}" IsEnabled="{Binding !IsOpen}"
PlaceholderText="Note" FontSize="12"/>
<Button Grid.Column="3" Content="Speichern" Command="{Binding SaveCommand}"
IsVisible="{Binding !IsOpen}" Margin="6,0,0,0" FontSize="11" Padding="8,3"/>
<TextBlock Grid.Column="4" Text="{Binding StatusMessage}" Foreground="Green" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<Grid Grid.Row="4" ColumnDefinitions="*,*" Margin="0,14,0,0">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="10" Margin="0,0,6,0">
<StackPanel Spacing="6">
<TextBlock Text="Abschnitt abschließen" FontSize="12" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="*,100" ColumnSpacing="6">
<TextBox Grid.Column="0" Text="{Binding NewSectionLabel}" PlaceholderText="Bezeichnung"/>
<TextBox Grid.Column="1" Text="{Binding NewSectionEndDateText}" PlaceholderText="TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding SectionValidationMessage}" Foreground="Red" FontSize="11"
IsVisible="{Binding SectionValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Abschnitt für alle Schüler abschließen" Command="{Binding CloseSectionCommand}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Border>
<Border Grid.Column="1" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="10" Margin="6,0,0,0">
<StackPanel Spacing="6">
<TextBlock Text="Halbjahresnote aus Abschnitten" FontSize="12" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding RollupPeriodOptions}" SelectedItem="{Binding RollupPeriod}"
HorizontalAlignment="Stretch"/>
<TextBlock Text="{Binding RollupStatusMessage}" Foreground="Green" FontSize="11"
IsVisible="{Binding RollupStatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Für alle Schüler übernehmen" Command="{Binding ApplyRollupCommand}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Border>
</Grid>
<Button Grid.Row="5" 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 ParticipationWizardDialog : Window
{
public ParticipationWizardDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,64 @@
<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.ReportGradeDialog"
x:DataType="vm:ReportGradeDialogViewModel"
Title="Zeugnisnoten"
Width="820" Height="680" MinWidth="640" MinHeight="440"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding GroupLabel}" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SchemeSummary}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="12" Margin="0,12,0,10">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="Zeitraum:" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding PeriodOptions}" SelectedItem="{Binding SelectedPeriod}" MinWidth="170"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="Rundung:" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding RoundingOptions}" SelectedItem="{Binding RoundingRuleName}" MinWidth="140"/>
</StackPanel>
</StackPanel>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ReportGradeRow">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="180,90,*,120,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" FontSize="13"/>
<TextBlock Grid.Column="1" Text="{Binding CalculatedDisplay}" VerticalAlignment="Center"
FontSize="13" Opacity="0.7" ToolTip.Tip="Berechnet"/>
<TextBox Grid.Column="2" Text="{Binding OverrideValue}" PlaceholderText="Übersteuern (optional)"
IsEnabled="{Binding !IsLocked}"/>
<TextBox Grid.Column="3" Text="{Binding OverrideReason}" PlaceholderText="Begründung"
IsEnabled="{Binding !IsLocked}" Margin="6,0,0,0"/>
<TextBlock Grid.Column="4" Text="{Binding FinalDisplay}" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="10,0" FontSize="14"/>
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6">
<Button Content="Speichern" Command="{Binding SaveCommand}" IsEnabled="{Binding !IsLocked}"/>
<Button Content="{Binding LockLabel}" Command="{Binding ToggleLockCommand}"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="11"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="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"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,30 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class ReportGradeDialog : Window
{
public ReportGradeDialog() => InitializeComponent();
private async void OnExportClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not ReportGradeDialogViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Zeugnisnoten exportieren",
SuggestedFileName = $"Zeugnisnoten_{vm.GroupLabel}.csv",
FileTypeChoices = [new FilePickerFileType("CSV-Dateien") { Patterns = ["*.csv"] }],
});
if (file is null) return;
await File.WriteAllTextAsync(file.Path.LocalPath, vm.ExportCsv());
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,55 @@
<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.StudentGradesDialog"
x:DataType="vm:StudentGradesDialogViewModel"
Title="{Binding StudentName}"
Width="560" Height="600" MinWidth="480" MinHeight="360"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="{Binding StudentName}" FontSize="18" FontWeight="SemiBold"/>
<Button Grid.Row="1" Content=" Note hinzufügen" Command="{Binding AddEntryCommand}"
HorizontalAlignment="Left" Margin="0,10,0,10"/>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GradeEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="120,*,90,90" ColumnSpacing="6">
<ComboBox Grid.Column="0" ItemsSource="{x:Static vm:GradeCategoryDisplay.Options}"
SelectedItem="{Binding CategoryName}"/>
<TextBox Grid.Column="1" Text="{Binding Value}" PlaceholderText="Wert (z.B. 2 oder 11)"/>
<TextBox Grid.Column="2" Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
<NumericUpDown Grid.Column="3" Value="{Binding Weight}" Minimum="0" Maximum="10"
Increment="0.1" FormatString="0.#" ShowButtonSpinner="False"
ToolTip.Tip="Gewichtung"/>
</Grid>
<TextBox Text="{Binding Note}" PlaceholderText="Notiz (optional)" FontSize="12"/>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="11"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding CreatedAtDisplay}" FontSize="11" Opacity="0.5"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}" Margin="0,0,6,0"/>
<Button Grid.Column="2" Content="Löschen" Command="{Binding DeleteCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Grid.Row="2" Text="Noch keine Noten erfasst." Opacity="0.4" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,20,0,0"
IsVisible="{Binding !Entries.Count}"/>
<Button Grid.Row="3" Content="Fertig" HorizontalAlignment="Stretch" Margin="0,16,0,0" Click="OnClose"/>
</Grid>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class StudentGradesDialog : Window
{
public StudentGradesDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -77,9 +77,9 @@
<NumericUpDown Grid.Column="2" <NumericUpDown Grid.Column="2"
Value="{Binding CatalogGradeLevel}" Value="{Binding CatalogGradeLevel}"
Minimum="1" Maximum="13" FormatString="0"/> Minimum="1" Maximum="13" FormatString="0"/>
<Button Grid.Column="4" Content="Import JSON" Click="OnImportClick" <Button Grid.Column="4" Content="JSON importieren" Click="OnImportClick"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/> IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Grid.Column="6" Content="Export JSON" Click="OnExportClick" <Button Grid.Column="6" Content="JSON exportieren" Click="OnExportClick"
IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/> IsEnabled="{Binding CatalogSubject, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</Grid> </Grid>
@@ -222,6 +222,50 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Notenschema -->
<ContentPage Header="Notenschema">
<ContentPage.Resources>
<DataTemplate x:Key="GradingSchemeTemplate" DataType="vm:GradingSchemeEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,90,Auto,90,Auto,90" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Klausuren %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="1" Value="{Binding ExamsPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="2" Text="Mitarbeit %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="3" Value="{Binding ParticipationPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="4" Text="Sonstige %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="5" Value="{Binding OtherPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ContentPage.Resources>
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Voreinstellung der Gewichtung für die Zeugnisnote (Klausuren / Mitarbeit / Sonstige), je Gruppentyp. Kann pro Lerngruppe überschrieben werden."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ContentControl Content="{Binding ClassScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
<ContentControl Content="{Binding CourseScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -129,10 +129,62 @@
</ContentPage> </ContentPage>
<ContentPage Header="Noten"> <ContentPage Header="Noten">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> <ScrollViewer Padding="20">
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4" HorizontalAlignment="Center"/> <StackPanel Spacing="20">
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3" HorizontalAlignment="Center"/> <TextBlock Text="Notenentwicklung" FontSize="15" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding GradeHistory}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:StudentGradeHistoryGroup">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="14,12" Margin="0,0,0,12">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="SemiBold" FontSize="13"/>
<Border Grid.Column="1" Background="#E53935" CornerRadius="4" Padding="6,1"
IsVisible="{Binding HasWarnings}">
<TextBlock Text="Auffälligkeiten" FontSize="10" Foreground="White"/>
</Border>
</Grid>
<ItemsControl ItemsSource="{Binding Points}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="10"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:GradeHistoryPoint">
<StackPanel Width="46" VerticalAlignment="Bottom" ToolTip.Tip="{Binding TooltipText}">
<Border Classes="historybar" Height="{Binding BarHeight}" Width="16" CornerRadius="2"
HorizontalAlignment="Center" VerticalAlignment="Bottom"
Classes.warning="{Binding IsWarning}"/>
<TextBlock Text="{Binding Value}" FontSize="11" FontWeight="SemiBold"
HorizontalAlignment="Center" Margin="0,3,0,0"/>
<TextBlock Text="{Binding DateDisplay}" FontSize="9" Opacity="0.5"
HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.Styles>
<Style Selector="Border.historybar">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
</Style>
<Style Selector="Border.historybar.warning">
<Setter Property="Background" Value="#E53935"/>
</Style>
</ItemsControl.Styles>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Noten für diesen Schüler erfasst." Opacity="0.4"
IsVisible="{Binding !GradeHistory.Count}"/>
</StackPanel>
</ScrollViewer>
</ContentPage> </ContentPage>
<ContentPage Header="Dokumentation"> <ContentPage Header="Dokumentation">
+58 -18
View File
@@ -102,36 +102,46 @@ pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene
## 2. Noten & Zeugnisnoten ## 2. Noten & Zeugnisnoten
Modell `Grade` existiert in [Planning.cs](LehrerApp.Core/Models/Planning.cs), `GradeRepository` ebenfalls. Modell `Grade` existiert in [Planning.cs](LehrerApp.Core/Models/Planning.cs), `GradeRepository` ebenfalls.
Der Tab "Noten" in [GroupDetailView.axaml:83](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml#L83) ist ein Platzhalter. Der Tab "Noten" in [GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml) ist jetzt
vollständig umgesetzt (siehe unten), ebenso der gleichnamige Tab im Schülerdetail (2.5).
### 2.1 Notenübersicht der Gruppe ### 2.1 Notenübersicht der Gruppe
- [ ] **2.1.1** Matrix: Zeilen = Schüler, Spalten = alle Leistungen (Klausuren, Mitarbeit je Halbjahr, - [x] **2.1.1** Matrix: Zeilen = Schüler, Spalten = alle Leistungen (Klausuren, Mitarbeit je Halbjahr,
sonstige Noten). Zelle zeigt Note/Punkte. sonstige Noten). Zelle zeigt Note/Punkte.
- [ ] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`. - [x] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`.
- [ ] **2.1.3** Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte. - [x] **2.1.3** Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte.
- [ ] **2.1.4** Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt `GroupMembership.Period`. - [x] **2.1.4** Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt `GroupMembership.Period`.
### 2.2 Einzelnoten pflegen ### 2.2 Einzelnoten pflegen
- [ ] **2.2.1** Dialog "Note hinzufügen": Kategorie (`GradeCategory`), Wert, Datum, Gewichtung, Notiz. - [x] **2.2.1** Dialog "Note hinzufügen": Kategorie (`GradeCategory`), Wert, Datum, Gewichtung, Notiz.
- [ ] **2.2.2** Note bearbeiten / löschen mit Historie (wer/wann geändert) — mindestens `CreatedAt` sichtbar. - [x] **2.2.2** Note bearbeiten / löschen mit Historie (wer/wann geändert) — mindestens `CreatedAt` sichtbar.
- [ ] **2.2.3** Sammelerfassung: eine Note (z.B. Hausaufgabenkontrolle) für die ganze Gruppe auf einmal. - [x] **2.2.3** Sammelerfassung: eine Note (z.B. Hausaufgabenkontrolle) für die ganze Gruppe auf einmal.
### 2.3 Gewichtungsschema ### 2.3 Gewichtungsschema
- [ ] **2.3.1** Neues Modell `GradingScheme` je Gruppe: prozentuale Anteile von - [x] **2.3.1** Neues Modell `GradingScheme` je Gruppe: prozentuale Anteile von
Klausuren / Mitarbeit / sonstige Leistungen (z.B. 50/40/10). Klausuren / Mitarbeit / sonstige Leistungen (z.B. 50/40/10).
- [ ] **2.3.2** UI zur Bearbeitung, Validierung auf Summe 100 %. - [x] **2.3.2** UI zur Bearbeitung, Validierung auf Summe 100 %.
- [ ] **2.3.3** Voreinstellung je Gruppentyp (`Class` vs. `Course`) in den Einstellungen. - [x] **2.3.3** Voreinstellung je Gruppentyp (`Class` vs. `Course`) in den Einstellungen.
### 2.4 Zeugnisnote ### 2.4 Zeugnisnote
- [ ] **2.4.1** Berechnung der Zeugnisnote aus Schema (2.3) + allen Teilnoten, - [x] **2.4.1** Berechnung der Zeugnisnote aus Schema (2.3) + allen Teilnoten,
Rundungsregel konfigurierbar (kaufmännisch / pädagogisch abweichbar). Rundungsregel konfigurierbar (kaufmännisch / pädagogisch abweichbar).
- [ ] **2.4.2** Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum). - [x] **2.4.2** Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum).
- [ ] **2.4.3** Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin. - [x] **2.4.3** Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin.
- [ ] **2.4.4** Export der Zeugnisnotenliste (siehe 11.2). - [x] **2.4.4** Export der Zeugnisnotenliste (siehe 11.2).
### 2.5 Notenentwicklung ### 2.5 Notenentwicklung
- [ ] **2.5.1** Verlaufsdiagramm pro Schüler über das Schuljahr (im Schülerdetail). - [x] **2.5.1** Verlaufsdiagramm pro Schüler über das Schuljahr (im Schülerdetail).
- [ ] **2.5.2** Auffälligkeiten markieren: Abfall um ≥ 1 Note, Versetzungsgefährdung (Note 5/6). - [x] **2.5.2** Auffälligkeiten markieren: Abfall um ≥ 1 Note, Versetzungsgefährdung (Note 5/6).
Umgesetzt über [GradeOverviewViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs)
(Matrix-Tab, Einzelnoten-Dialoge), [ReportGradeViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs)
(Zeugnisnoten-Dialog) und die neuen Modelle `GradingScheme`/`ReportGrade` in
[Planning.cs](LehrerApp.Core/Models/Planning.cs). Das Gewichtungsschema wird pro Gruppe gesucht, sonst
die Voreinstellung des Gruppentyps (Einstellungen → Notenschema), sonst ein Fallback 50/40/10 verwendet;
Bereiche ohne Werte werden bei der Berechnung ausgelassen und die verbleibenden Prozentanteile neu normiert
(`GradingService.CalculateReportGrade()`). Notenentwicklung im Schülerdetail zeigt ein einfaches
Balken-Sparkline je Lerngruppe über alle Klausur- und Einzelnoten-Einträge chronologisch.
--- ---
@@ -170,10 +180,40 @@ Mitarbeit-Note für denselben Zeitraum statt sie zu duplizieren (erkannt über d
### 3.3 Sitzungen ### 3.3 Sitzungen
- [ ] **3.3.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen - [ ] **3.3.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen
(Datum + Thema übernehmen) — Abhängigkeit zu 4.2. (Datum + Thema übernehmen) — Abhängigkeit zu 4.2.
- [ ] **3.3.2** Anwesenheit in der Sitzung erfassen (fehlend/verspätet), Verknüpfung mit 5.2. - [x] **3.3.2** Anwesenheit in der Sitzung erfassen — als `ParticipationEntry.Attendance`
(krank: Entschuldigung offen/entschuldigt/unentschuldigt), nicht als "verspätet"; siehe unten.
Vorgriff auf 5.2, ersetzt dessen 5.2.1/5.2.4 aber nicht vollständig (kein Fehlzeitenmodul
über alle Stundentypen hinweg, nur innerhalb von Mitarbeit-Sitzungen).
- [ ] **3.3.3** Sitzung bearbeiten/löschen inkl. Rückfrage bei vorhandenen Bewertungen. - [ ] **3.3.3** Sitzung bearbeiten/löschen inkl. Rückfrage bei vorhandenen Bewertungen.
- [ ] **3.3.4** Sitzungen mehrerer Gruppen im Kalenderüberblick. - [ ] **3.3.4** Sitzungen mehrerer Gruppen im Kalenderüberblick.
### Abschnittsnoten & Mitarbeits-Assistent (nicht aus dieser Liste, eigener Workflow-Bedarf)
Ergänzt den Workflow "regelmäßig (alle 47 Wochen) mündliche Noten zu einer Abschnittsnote
zusammenziehen, daraus die Halbjahresnote bilden":
- `ParticipationEntry.HomeworkMissing` (bool) und `.Attendance` (`AttendanceStatus?`:
`ExcusePending`/`Excused`/`Unexcused`) — editierbar direkt im Bewertungsraster
([ParticipationTabView.axaml](LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml), Spalten
"HA"/"Anwesenheit") und im neuen Mitarbeits-Assistenten. `ExcusePending` ist bewusst ein
Zwischenzustand, da die Entschuldigung meist erst später eintrifft.
- Neues Modell `ParticipationSection` (Abschnitt: Start/Ende/Bezeichnung je Gruppe) — nur
abgeschlossene Abschnitte werden gespeichert, der laufende Zeitraum ergibt sich aus dem Ende
des letzten Abschnitts bis heute.
- Neues Fenster **Mitarbeits-Assistent**
([ParticipationWizardViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs),
[ParticipationWizardDialog.axaml](LehrerApp.Desktop/Views/Groups/ParticipationWizardDialog.axaml)):
Zeitleiste je Schüler (Sitzungen als Bewertungs-Badges, Klausurtermine, Symbole für Hausaufgaben/
Bemerkung/Anwesenheit), gruppiert nach Abschnitten. "Abschnitt abschließen" berechnet für alle
Schüler einen Notenvorschlag aus den Sitzungen im Zeitraum und speichert ihn als `Grade`
(`Category = Participation`, `Note = "Abschnitt: <Bezeichnung>"`), individuell nachjustierbar.
"Halbjahresnote aus Abschnitten übernehmen" mittelt die Abschnittsnoten eines Zeitraums und
speichert sie über denselben `Grade.Note`-Tag wie der bestehende "Ø Mitarbeitsnote"-Dialog (3.2) —
beide Wege sind kompatibel, der Assistent baut auf 3.2 auf statt es zu ersetzen.
- Dashboard-Kachel "Offene Entschuldigungen": listet alle `ExcusePending`-Einträge der letzten
21 Tage gruppenübergreifend mit Direktauflösung; ältere Einträge werden ausgeblendet statt
automatisch entschieden (pädagogische Entscheidung bleibt bei der Lehrkraft).
--- ---
## 4. Unterrichtsplanung ## 4. Unterrichtsplanung
+21
View File
@@ -37,6 +37,26 @@ ein eigenes Feld wie `Student.SchoolEntryDate` zu verwenden.
Pro Kombination aus Schüler und Lerngruppe darf es höchstens eine Zuordnung Pro Kombination aus Schüler und Lerngruppe darf es höchstens eine Zuordnung
geben. geben.
## Gewichtungsschema (`GradingScheme`)
Legt die prozentuale Gewichtung von Klausuren, Mitarbeit und sonstigen Leistungen für die
Zeugnisnote fest (Kapitel 2.3/2.4). Ein Datensatz ist entweder:
- gruppenspezifisch (`GroupId` gesetzt, `GroupType` leer), oder
- eine Voreinstellung je Gruppentyp (`GroupType` gesetzt, `GroupId` leer, in den Einstellungen
gepflegt).
Bei der Zeugnisnotenberechnung wird zuerst nach einem gruppenspezifischen Schema gesucht, sonst
nach der Voreinstellung des Gruppentyps, sonst greift ein fest codierter Fallback (50/40/10).
## Zeugnisnote (`ReportGrade`)
Eine Zeugnisnote gehört zu genau einem Schüler, einer Lerngruppe und einem Zeitraum
(`Period`: "Gesamtes Schuljahr" / "1. Halbjahr" / "2. Halbjahr" — freier Text, keine Verknüpfung
zu `MembershipPeriod`). `CalculatedValue` ist das zuletzt berechnete Ergebnis, `OverrideValue`
überschreibt es bei pädagogischem Ermessen und erfordert `OverrideReason`. Nach dem Festschreiben
(`IsLocked`) wird der Datensatz bei einer Neuberechnung nicht mehr verändert.
## Bewusst gespeicherte Momentaufnahmen ## Bewusst gespeicherte Momentaufnahmen
Einige berechnete Werte bleiben absichtlich gespeichert: Einige berechnete Werte bleiben absichtlich gespeichert:
@@ -65,6 +85,7 @@ Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes:
- Gruppenzuordnung: `StudentId + GroupId` - Gruppenzuordnung: `StudentId + GroupId`
- Klausurergebnis: `ExamId + StudentId` - Klausurergebnis: `ExamId + StudentId`
- Mitarbeitseintrag: `SessionId + StudentId` - Mitarbeitseintrag: `SessionId + StudentId`
- Zeugnisnote: `StudentId + GroupId + Period`
- Fach: normalisierter Fachname - Fach: normalisierter Fachname
Altdaten werden beim Öffnen der Datenbank automatisch migriert. Die Migration Altdaten werden beim Öffnen der Datenbank automatisch migriert. Die Migration