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 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
{
Unit? GetById(Guid id);
@@ -116,6 +130,12 @@ public interface IParticipationAspectRepository
void Save(ParticipationAspect aspect);
void Delete(Guid id);
}
public interface IParticipationSectionRepository
{
List<ParticipationSection> GetByGroup(Guid groupId);
void Save(ParticipationSection section);
void Delete(Guid id);
}
public interface ISubjectRepository
{
List<Subject> GetAll();
+25
View File
@@ -20,9 +20,34 @@ public class ParticipationEntry
public List<AspectRating> Ratings { get; set; } = [];
public List<CompetencyRating> CompetencyRatings { get; set; } = [];
public string? Note { get; set; }
public bool HomeworkMissing { get; set; }
public AttendanceStatus? Attendance { get; set; }
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 string Code { get; set; } = "";
+35
View File
@@ -14,6 +14,22 @@ public class Grade
}
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 Guid Id { get; set; } = Guid.NewGuid();
@@ -45,3 +61,22 @@ public class Lesson
}
public enum UnitStatus { Planned, Active, Completed }
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);
}
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)
{
var numeric = grades
@@ -73,4 +83,51 @@ public class GradingService
var total = numeric.Sum(g => g.Weight);
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<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
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<Unit> Units => _db.GetCollection<Unit>("units");
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<ParticipationEntry> ParticipationEntries => _db.GetCollection<ParticipationEntry>("participation");
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<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
@@ -152,6 +155,12 @@ public class LiteDbContext : IDisposable
BsonExpression.Create("STRING($.ExamId) + ':' + STRING($.StudentId)"), unique: true);
Grades.EnsureIndex(x => x.StudentId);
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);
Units.EnsureIndex(x => x.GroupId);
Lessons.EnsureIndex(x => x.UnitId);
@@ -167,6 +176,7 @@ public class LiteDbContext : IDisposable
ParticipationEntries.EnsureIndex("ux_session_student",
BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true);
ParticipationAspects.EnsureIndex(x => x.GroupId);
ParticipationSections.EnsureIndex(x => x.GroupId);
Subjects.EnsureIndex(x => x.Name);
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
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 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 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 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 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<IExamResultRepository, ExamResultRepository>();
services.AddSingleton<IGradeRepository, GradeRepository>();
services.AddSingleton<IGradingSchemeRepository, GradingSchemeRepository>();
services.AddSingleton<IReportGradeRepository, ReportGradeRepository>();
services.AddSingleton<IGradingKeyTemplateRepository, GradingKeyTemplateRepository>();
services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>();
@@ -57,6 +59,7 @@ public static class AppBootstrapper
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
services.AddSingleton<IParticipationRepository, ParticipationRepository>();
services.AddSingleton<IParticipationAspectRepository, ParticipationAspectRepository>();
services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>();
services.AddSingleton<ISubjectRepository, SubjectRepository>();
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
@@ -111,6 +114,7 @@ public static class AppBootstrapper
services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentDetailViewModel>();
services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>();
@@ -17,8 +17,13 @@ public partial class DashboardViewModel : ObservableObject
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participationEntries;
private readonly IStudentRepository _students;
private readonly SchoolYearService _sy;
private const int OpenExcuseMaxAgeDays = 21;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = "";
@@ -30,15 +35,19 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; }
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();
}
@@ -80,6 +89,41 @@ public partial class DashboardViewModel : ObservableObject
CalendarMonth = FirstOfMonth(now);
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()
@@ -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 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 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 ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { 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,
IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades,
ParticipationTabViewModel participationTab)
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades;
ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab;
}
public void LoadGroup(Guid id)
@@ -214,6 +216,7 @@ public partial class GroupDetailViewModel : ObservableObject
LoadStudents();
ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
}
private void ReloadExams()
@@ -28,6 +28,7 @@ public partial class ParticipationTabViewModel : ObservableObject
public Guid GroupId => _groupId;
public string SchoolYear => _schoolYear;
public GradingSystem GradingSystem => _gradingSystem;
public string GroupLabel { get; private set; } = "";
[ObservableProperty] private ParticipationSessionItem? _selectedSession;
[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<ParticipationTabViewModel, Task>? OnQuickInput { get; set; }
public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; }
public Func<ParticipationTabViewModel, Task>? OnOpenWizard { get; set; }
public ParticipationTabViewModel(
IParticipationSessionRepository sessions,
@@ -72,6 +74,7 @@ public partial class ParticipationTabViewModel : ObservableObject
_subjectId = group?.SubjectId;
_gradeLevel = group?.GradeLevel ?? 0;
_gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6;
GroupLabel = group?.Name ?? "";
HasCompetencyCatalog = _subjectId.HasValue
&& _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);
row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, 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);
}
QuickInputCommand.NotifyCanExecuteChanged();
@@ -185,6 +190,22 @@ public partial class ParticipationTabViewModel : ObservableObject
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]
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
@@ -259,6 +280,13 @@ public partial class ParticipationTabViewModel : ObservableObject
await OnComputeGrade(this);
}
[RelayCommand]
private async Task OpenWizard()
{
if (OnOpenWizard is null) return;
await OnOpenWizard(this);
}
[RelayCommand]
private void DeleteSession()
{
@@ -310,8 +338,16 @@ public partial class ParticipationStudentRow : ObservableObject
public ObservableCollection<RatingCell> Cells { 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?>? 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,
List<AspectColumnDef> aspects, List<string> competencyCodes)
@@ -320,6 +356,8 @@ public partial class ParticipationStudentRow : ObservableObject
Name = name;
_entry = entry;
_aspectDefs = aspects;
_homeworkMissing = entry.HomeworkMissing;
_attendance = entry.Attendance;
foreach (var a in aspects)
{
@@ -347,6 +385,61 @@ public partial class ParticipationStudentRow : ObservableObject
cell?.SetValue(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 ──────────────────────────────────────────────────────
@@ -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 ICompetencyDomainRepository _domainRepo;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly IGradingSchemeRepository _gradingSchemes;
private readonly GradingService _grading;
// ── Fächer ────────────────────────────────────────────────────────────────
@@ -45,17 +46,39 @@ public partial class SettingsViewModel : ObservableObject
public List<string> GradingSystemOptions { get; } = ["Noten 16", "Punkte 015"];
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
// ── Konstruktor ───────────────────────────────────────────────────────────
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading)
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading)
{
_subjects = subjects;
_domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates;
_gradingSchemes = gradingSchemes;
_grading = grading;
LoadSubjects();
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 ────────────────
@@ -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 ───────────────────────────────────────────────────────────────
public class SubjectListItem(Subject s)
@@ -2,7 +2,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students;
@@ -72,6 +74,9 @@ public partial class StudentDetailViewModel : ObservableObject
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IDocumentationRepository _docs;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IGradeRepository _grades;
[ObservableProperty] private Student? _student;
[ObservableProperty] private string _studentTitle = "";
@@ -83,16 +88,19 @@ public partial class StudentDetailViewModel : ObservableObject
public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = [];
public ObservableCollection<ContactItem> Contacts { get; } = [];
public ObservableCollection<StudentGradeHistoryGroup> GradeHistory { get; } = [];
public bool HasNoContacts => Contacts.Count == 0;
public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; }
public Action<ContactItem>? OnViewAddress { get; set; }
public StudentDetailViewModel(IStudentRepository students,
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
IDocumentationRepository docs)
IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults,
IGradeRepository grades)
{
_students = students; _memberships = memberships;
_groups = groups; _subjects = subjects; _docs = docs;
_exams = exams; _examResults = examResults; _grades = grades;
}
public void LoadStudent(Guid id)
@@ -104,12 +112,16 @@ public partial class StudentDetailViewModel : ObservableObject
EditLastName = Student.LastName;
GroupMemberships.Clear();
GradeHistory.Clear();
foreach (var membership in _memberships.GetByStudent(Student.Id))
{
var g = _groups.GetById(membership.GroupId);
if (g is null) continue;
var subject = g.SubjectId is Guid subjectId ? _subjects.GetById(subjectId)?.Name ?? "" : "";
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();
@@ -128,6 +140,49 @@ public partial class StudentDetailViewModel : ObservableObject
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 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 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 Contact Model { get; }
@@ -167,6 +167,39 @@
</StackPanel>
</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 -->
<Border Grid.Column="0" Grid.Row="2" Grid.ColumnSpan="2"
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 -->
<ContentPage Header="Noten">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
<views:GradeOverviewTabView DataContext="{Binding GradeOverviewTab}"/>
</ContentPage>
<!-- Tab: Planung -->
@@ -48,7 +48,7 @@
<DataGridTextColumn Header="Schüler" Binding="{Binding Name}" Width="2*"/>
<DataGridTextColumn Header="Sitzungen" Binding="{Binding SessionCount}" 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"/>
</DataGrid.Columns>
</DataGrid>
@@ -10,7 +10,7 @@
<Border Grid.Column="0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
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">
<Button Content=" Sitzung" Command="{Binding AddSessionCommand}" HorizontalAlignment="Stretch"/>
@@ -20,7 +20,10 @@
<Button Grid.Row="1" Content="Ø Mitarbeitsnote" Command="{Binding ComputeGradeCommand}"
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}"
SelectedItem="{Binding SelectedSession}"
BorderThickness="0">
@@ -48,7 +51,7 @@
<ToggleButton Content="◇ Kompetenzen"
IsChecked="{Binding CompetencyTagsVisible}"
FontSize="11" Padding="8,3"/>
<ToggleButton Content="◈ Schüler-Ratings"
<ToggleButton Content="◈ Schüler-Bewertungen"
IsChecked="{Binding StudentCompetencyRatingsVisible}"
FontSize="11" Padding="8,3"/>
</StackPanel>
@@ -24,6 +24,7 @@ public partial class ParticipationTabView : UserControl
vm.OnAddSession = ShowAddSessionDialog;
vm.OnQuickInput = ShowQuickInputDialog;
vm.OnComputeGrade = ShowComputeGradeDialog;
vm.OnOpenWizard = ShowWizardDialog;
vm.Aspects.CollectionChanged += (_, _) => BuildColumns();
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)
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
{
0 => "Q", 1 => "W", 2 => "E", 3 => "R", 4 => "T", _ => ""
@@ -163,4 +237,24 @@ public partial class ParticipationTabView : UserControl
if (owner is not null)
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"
Value="{Binding CatalogGradeLevel}"
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}}"/>
<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}}"/>
</Grid>
@@ -222,6 +222,50 @@
</ScrollViewer>
</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>
</Grid>
</UserControl>
@@ -129,10 +129,62 @@
</ContentPage>
<ContentPage Header="Noten">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4" HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3" HorizontalAlignment="Center"/>
</StackPanel>
<ScrollViewer Padding="20">
<StackPanel Spacing="20">
<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>
</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 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
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.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.
- [ ] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`.
- [ ] **2.1.3** Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte.
- [ ] **2.1.4** Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt `GroupMembership.Period`.
- [x] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`.
- [x] **2.1.3** Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte.
- [x] **2.1.4** Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt `GroupMembership.Period`.
### 2.2 Einzelnoten pflegen
- [ ] **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.
- [ ] **2.2.3** Sammelerfassung: eine Note (z.B. Hausaufgabenkontrolle) für die ganze Gruppe auf einmal.
- [x] **2.2.1** Dialog "Note hinzufügen": Kategorie (`GradeCategory`), Wert, Datum, Gewichtung, Notiz.
- [x] **2.2.2** Note bearbeiten / löschen mit Historie (wer/wann geändert) — mindestens `CreatedAt` sichtbar.
- [x] **2.2.3** Sammelerfassung: eine Note (z.B. Hausaufgabenkontrolle) für die ganze Gruppe auf einmal.
### 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).
- [ ] **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.2** UI zur Bearbeitung, Validierung auf Summe 100 %.
- [x] **2.3.3** Voreinstellung je Gruppentyp (`Class` vs. `Course`) in den Einstellungen.
### 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).
- [ ] **2.4.2** Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum).
- [ ] **2.4.3** Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin.
- [ ] **2.4.4** Export der Zeugnisnotenliste (siehe 11.2).
- [x] **2.4.2** Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum).
- [x] **2.4.3** Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin.
- [x] **2.4.4** Export der Zeugnisnotenliste (siehe 11.2).
### 2.5 Notenentwicklung
- [ ] **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.1** Verlaufsdiagramm pro Schüler über das Schuljahr (im Schülerdetail).
- [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.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen
(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.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
+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
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
Einige berechnete Werte bleiben absichtlich gespeichert:
@@ -65,6 +85,7 @@ Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes:
- Gruppenzuordnung: `StudentId + GroupId`
- Klausurergebnis: `ExamId + StudentId`
- Mitarbeitseintrag: `SessionId + StudentId`
- Zeugnisnote: `StudentId + GroupId + Period`
- Fach: normalisierter Fachname
Altdaten werden beim Öffnen der Datenbank automatisch migriert. Die Migration