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.
428 lines
18 KiB
C#
428 lines
18 KiB
C#
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);
|
||
}
|