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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user