Neue Klasse ParticipationRatingScale (Core) ist die einzige Quelle für Rohwerte je Aspekt-Typ. Scale3/Binary liegen bewusst direkt auf derselben -2..+2-Achse wie Scale5 (nur mit weniger Zwischenschritten), damit die bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote unverändert kompatibel bleibt. Points ist grundverschieden (echter Zählwert 0..MaxPoints, neues Feld auf ParticipationAspect) und wird nur zur Aggregation linear auf dieselbe Achse normiert. Ohne diese Normierung hätte ein Punkte-Aspekt die Mitarbeitsnote verfälscht: drei Stellen summierten bisher den Rohwert direkt (ParticipationGradeDialog- ViewModel.Recompute, ParticipationWizardViewModels.WeightedRating und .ComputeSuggestion) - alle drei sind jetzt auf die Normierung umgestellt. Raster: Punkte-Aspekte zeigen ein NumericUpDown statt fester Stufen-Buttons. Schnelleingabe-Dialog: Zifferntasten/+/- sind jetzt typabhängig, Legende zeigt live die für den aktuellen Aspekt gültigen Tasten. Aspekt-Verwaltung um "Max. Punkte"-Feld ergänzt (nur bei Typ "Punkte" sichtbar). Bewusst nicht angefasst: die Trendlinien-Visualisierung im Mitarbeits-Assistenten bleibt fest auf die drei Standardaspekte zugeschnitten - eigener, größerer Umbau. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
524 lines
24 KiB
C#
524 lines
24 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 Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
||
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;
|
||
// Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
|
||
// Gewichtung, analog zu ParticipationGradeDialogViewModel.
|
||
private readonly Dictionary<string, ParticipationAspect> _aspectsByKey;
|
||
|
||
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 _newSectionLabelError = "";
|
||
[ObservableProperty] private string _newSectionEndDateTextError = "";
|
||
[ObservableProperty] private ParticipationPeriodOption _rollupPeriod;
|
||
[ObservableProperty] private string _rollupStatusMessage = "";
|
||
[ObservableProperty] private double _timelineZoom = 1.0;
|
||
[ObservableProperty] private bool _showQuality = true;
|
||
[ObservableProperty] private bool _showQuantity = true;
|
||
[ObservableProperty] private bool _showWorkphase = true;
|
||
[ObservableProperty] private bool _showDataPoints = true;
|
||
[ObservableProperty] private bool _showWeightedTrend = true;
|
||
[ObservableProperty] private bool _showQualityTrend;
|
||
[ObservableProperty] private bool _showQuantityTrend;
|
||
[ObservableProperty] private bool _showWorkphaseTrend;
|
||
|
||
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, IGroupMembershipRepository memberships,
|
||
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;
|
||
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
||
|
||
var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
|
||
.GroupBy(a => a.Key).Select(g => g.Last()).ToList();
|
||
_aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight);
|
||
_aspectsByKey = applicableAspects.ToDictionary(a => a.Key);
|
||
var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear);
|
||
_students = students.GetByGroup(groupId)
|
||
.Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership)
|
||
|| GroupMembershipService.Overlaps(membership, schoolYearRange.From, schoolYearRange.To))
|
||
.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)
|
||
{
|
||
if (!IsStudentActiveOn(studentId, session.Date)) continue;
|
||
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))
|
||
{
|
||
if (!IsStudentActiveOn(studentId, exam.Date)) continue;
|
||
var result = _results.GetByExamAndStudent(exam.Id, studentId);
|
||
if (result is null) continue;
|
||
points.Add((exam.Date, BuildExamPoint(exam, result)));
|
||
}
|
||
foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
|
||
.Where(g => g.Category != GradeCategory.Participation)
|
||
.Where(g => IsStudentActiveOn(studentId, g.Date)))
|
||
{
|
||
points.Add((grade.Date, BuildOtherGradePoint(grade)));
|
||
}
|
||
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 weightedValue = entry is not null ? WeightedRating(entry) : null;
|
||
var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : "";
|
||
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)
|
||
{
|
||
Homework = entry is null ? null : HomeworkDisplay.Effective(entry),
|
||
AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
|
||
AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
|
||
AttendanceColor = AttendanceDisplay.Color(entry?.Attendance),
|
||
AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value)
|
||
?? new Dictionary<string, int>(),
|
||
WeightedValue = weightedValue,
|
||
OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem),
|
||
};
|
||
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 static WizardTimelinePoint BuildOtherGradePoint(Grade grade)
|
||
{
|
||
var category = grade.Category switch
|
||
{
|
||
GradeCategory.Oral => "Mündlich",
|
||
GradeCategory.Homework => "Hausaufgabe",
|
||
GradeCategory.Project => "Projekt",
|
||
_ => "Sonstige Leistung",
|
||
};
|
||
var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim();
|
||
var label = $"{detail}: {grade.Value}";
|
||
return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label,
|
||
hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}");
|
||
}
|
||
|
||
private double? WeightedRating(ParticipationEntry entry)
|
||
{
|
||
if (entry.Ratings.Count == 0) return null;
|
||
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 += NormalizedValue(r) * w; weightSum += w;
|
||
}
|
||
return weightSum <= 0 ? null : valueSum / weightSum;
|
||
}
|
||
|
||
private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect)
|
||
? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
|
||
: r.Value;
|
||
|
||
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.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry));
|
||
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
|
||
_entries.Save(entry);
|
||
point.Homework = entry.Homework;
|
||
}
|
||
|
||
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.Present,
|
||
AttendanceStatus.Present => AttendanceStatus.ExcusePending,
|
||
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
|
||
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
|
||
AttendanceStatus.Unexcused => AttendanceStatus.Truant,
|
||
AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent,
|
||
AttendanceStatus.OtherSchoolEvent => null,
|
||
_ => null,
|
||
};
|
||
_entries.Save(entry);
|
||
// Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen.
|
||
// Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen.
|
||
point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance);
|
||
point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
|
||
point.AttendanceIcon = AttendanceDisplay.ShortLabel(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)
|
||
{
|
||
if (!StudentOverlaps(studentId, section.StartDate, section.EndDate)) continue;
|
||
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)
|
||
.Where(s => IsStudentActiveOn(studentId, s.Date)))
|
||
{
|
||
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 += NormalizedValue(r) * 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()
|
||
{
|
||
NewSectionLabelError = ""; NewSectionEndDateTextError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(NewSectionLabel)) { NewSectionLabelError = "Bezeichnung erforderlich."; valid = false; }
|
||
|
||
DateOnly end = default;
|
||
if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null,
|
||
DateTimeStyles.None, out end))
|
||
{ NewSectionEndDateTextError = "Format TT.MM.JJJJ."; valid = false; }
|
||
|
||
if (!valid) return;
|
||
|
||
var start = ComputeOpenStart();
|
||
if (end < start) { NewSectionEndDateTextError = "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,
|
||
});
|
||
}
|
||
|
||
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))
|
||
.Where(g => IsStudentActiveOn(student.Id, g.Date))
|
||
.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,
|
||
};
|
||
|
||
private bool IsStudentActiveOn(Guid studentId, DateOnly date) =>
|
||
!_membershipsByStudent.TryGetValue(studentId, out var membership)
|
||
|| GroupMembershipService.IsActiveOn(membership, date);
|
||
|
||
private bool StudentOverlaps(Guid studentId, DateOnly from, DateOnly to) =>
|
||
!_membershipsByStudent.TryGetValue(studentId, out var membership)
|
||
|| GroupMembershipService.Overlaps(membership, from, to);
|
||
}
|
||
|
||
// ── 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 DateOnly Date { get; }
|
||
public string DateDisplay { get; }
|
||
public bool IsExam { get; }
|
||
public string RatingLabel { get; }
|
||
public string ExamLabel { get; }
|
||
public bool HasNote { get; }
|
||
public string TooltipText { get; }
|
||
public IReadOnlyDictionary<string, int> AspectValues { get; init; } = new Dictionary<string, int>();
|
||
public double? WeightedValue { get; init; }
|
||
public string OverallGrade { get; init; } = "";
|
||
|
||
[ObservableProperty] private HomeworkStatus? _homework;
|
||
[ObservableProperty] private string _attendanceIcon = "";
|
||
[ObservableProperty] private string _attendanceTooltip = "Anwesend";
|
||
[ObservableProperty] private string _attendanceColor = "";
|
||
|
||
public bool IsAbsent => AttendanceIcon.Length > 0;
|
||
public string AttendanceButtonLabel => AttendanceIcon.Length > 0 ? AttendanceIcon : "·";
|
||
public bool HasHomeworkStatus => Homework is not null;
|
||
public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework);
|
||
public string HomeworkTooltip => HomeworkDisplay.Label(Homework);
|
||
public string HomeworkColor => HomeworkDisplay.Color(Homework);
|
||
|
||
partial void OnHomeworkChanged(HomeworkStatus? value)
|
||
{
|
||
OnPropertyChanged(nameof(HasHomeworkStatus));
|
||
OnPropertyChanged(nameof(HomeworkSymbol));
|
||
OnPropertyChanged(nameof(HomeworkTooltip));
|
||
OnPropertyChanged(nameof(HomeworkColor));
|
||
}
|
||
|
||
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)
|
||
{
|
||
Date = date;
|
||
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);
|
||
}
|