Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
T

1246 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.Collections.Generic;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Tab-ViewModel ─────────────────────────────────────────────────────────────
public partial class ParticipationTabViewModel : ObservableObject
{
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IParticipationAspectRepository _aspects;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IGroupRepository _groups;
private readonly ICompetencyDomainRepository _competencyDomains;
private Guid _groupId;
private string _schoolYear = "";
private Guid? _subjectId;
private int _gradeLevel;
private GradingSystem _gradingSystem;
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.";
[ObservableProperty] private bool _competencyTagsVisible;
[ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _studentCompetencyRatingsVisible;
[ObservableProperty] private int _rebuildColumnsSignal;
[ObservableProperty] private bool _isReadOnly;
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
public List<string> ActiveCompetencyCodes { get; private set; } = [];
public ObservableCollection<ParticipationSessionItem> Sessions { get; } = [];
public ObservableCollection<ParticipationStudentRow> StudentRows { get; } = [];
public ObservableCollection<AspectColumnDef> Aspects { get; } = [];
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
public Func<Task<ParticipationSession?>>? OnAddSession { get; set; }
public Func<ParticipationSession, Task<ParticipationSession?>>? OnEditSession { get; set; }
public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; }
public Func<ParticipationTabViewModel, Task>? OnStatusQuickInput { get; set; }
public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; }
public Func<ParticipationTabViewModel, Task>? OnOpenWizard { get; set; }
public Func<ParticipationTabViewModel, Task>? OnManageAspects { get; set; }
public ParticipationTabViewModel(
IParticipationSessionRepository sessions,
IParticipationRepository entries,
IParticipationAspectRepository aspects,
IStudentRepository students,
IGroupMembershipRepository memberships,
IGroupRepository groups,
ICompetencyDomainRepository competencyDomains)
{
_sessions = sessions; _entries = entries;
_aspects = aspects; _students = students;
_memberships = memberships; _groups = groups;
_competencyDomains = competencyDomains;
}
public void Initialize(Guid groupId, string schoolYear, bool isReadOnly = false)
{
_groupId = groupId;
_schoolYear = schoolYear;
IsReadOnly = isReadOnly;
var group = _groups.GetById(groupId);
_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;
LoadAspects();
LoadSessions();
}
private void LoadAspects()
{
Aspects.Clear();
var defaults = _aspects.GetDefaults();
var specific = _aspects.GetByGroup(_groupId);
var all = defaults.Concat(specific).ToList();
if (!all.Any())
{
foreach (var a in DefaultParticipationAspects.All)
Aspects.Add(new AspectColumnDef(a));
}
else
{
foreach (var a in all) Aspects.Add(new AspectColumnDef(a));
}
}
public void LoadSessions()
{
var selectedId = SelectedSession?.Id;
Sessions.Clear();
foreach (var s in _sessions.GetByGroup(_groupId))
Sessions.Add(new ParticipationSessionItem(s));
SelectedSession = Sessions.FirstOrDefault(s => s.Id == selectedId) ?? Sessions.FirstOrDefault();
}
partial void OnSelectedSessionChanged(ParticipationSessionItem? value)
{
OnPropertyChanged(nameof(SelectedSessionDisplay));
EditSessionCommand.NotifyCanExecuteChanged();
if (value is null)
{
StudentRows.Clear();
CompetencyTagGroups.Clear();
ActiveCompetencyCodes = [];
QuickInputCommand.NotifyCanExecuteChanged();
StatusQuickInputCommand.NotifyCanExecuteChanged();
RebuildColumnsSignal++;
return;
}
LoadCompetencyTags(value.Id); // sets ActiveCompetencyCodes first
LoadGrid(value.Id); // uses ActiveCompetencyCodes, fires RebuildColumnsSignal++
}
private void LoadGrid(Guid sessionId)
{
StudentRows.Clear();
var session = _sessions.GetById(sessionId);
var sessionDate = session?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var students = _students.GetByGroup(_groupId);
var memberships = _memberships.GetByGroup(_groupId);
var entries = _entries.GetBySession(sessionId);
foreach (var s in students)
{
var membership = memberships.FirstOrDefault(e => e.StudentId == s.Id);
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, sessionDate))
continue;
var entry = entries.FirstOrDefault(e => e.StudentId == s.Id)
?? new ParticipationEntry { SessionId = sessionId, StudentId = s.Id };
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();
StatusQuickInputCommand.NotifyCanExecuteChanged();
RebuildColumnsSignal++;
}
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value)
{
if (IsReadOnly) return;
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry
{
SessionId = sessionId,
StudentId = studentId,
};
var existing = entry.Ratings.FirstOrDefault(r => r.Key == key);
if (value is null)
{
if (existing is not null) entry.Ratings.Remove(existing);
}
else
{
if (existing is null) entry.Ratings.Add(new AspectRating { Key = key, Value = value.Value });
else existing.Value = value.Value;
}
_entries.Save(entry);
}
public void RefreshCurrentGrid()
{
if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
}
private void SaveHomework(Guid sessionId, Guid studentId, HomeworkStatus? value)
{
if (IsReadOnly) return;
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Homework = value;
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(value);
_entries.Save(entry);
}
private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value)
{
if (IsReadOnly) return;
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Attendance = value;
_entries.Save(entry);
}
[RelayCommand]
private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible;
partial void OnStudentCompetencyRatingsVisibleChanged(bool value) => RebuildColumnsSignal++;
private void LoadCompetencyTags(Guid sessionId)
{
CompetencyTagGroups.Clear();
var session = _sessions.GetById(sessionId);
ActiveCompetencyCodes = session?.CompetencyCodes?.ToList() ?? [];
if (!_subjectId.HasValue) return;
var active = ActiveCompetencyCodes.ToHashSet();
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel))
{
var group = new CompetencyTagGroup(domain.Name, domain.Code);
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
{
var tag = new CompetencyTag(item.Code, item.Description, active.Contains(item.Code));
tag.OnChanged = (code, sel) => OnTagToggled(code, sel);
group.Items.Add(tag);
}
if (group.Items.Count > 0)
CompetencyTagGroups.Add(group);
}
}
private void OnTagToggled(string code, bool selected)
{
if (SelectedSession is null) return;
var session = _sessions.GetById(SelectedSession.Id);
if (session is null) return;
if (selected) { if (!session.CompetencyCodes.Contains(code)) session.CompetencyCodes.Add(code); }
else { session.CompetencyCodes.Remove(code); }
_sessions.Save(session);
ActiveCompetencyCodes = session.CompetencyCodes.ToList();
if (StudentCompetencyRatingsVisible)
LoadGrid(SelectedSession.Id); // reloads rows with updated cells, fires RebuildColumnsSignal++
}
[RelayCommand]
private async Task AddSession()
{
if (OnAddSession is null) return;
var session = await OnAddSession();
if (session is null) return;
session.GroupId = _groupId;
_sessions.Save(session);
Sessions.Clear();
foreach (var s in _sessions.GetByGroup(_groupId))
Sessions.Add(new ParticipationSessionItem(s));
SelectedSession = Sessions.FirstOrDefault(s => s.Id == session.Id);
}
[RelayCommand(CanExecute = nameof(CanEditSession))]
private async Task EditSession()
{
if (SelectedSession is null || OnEditSession is null) return;
var session = _sessions.GetById(SelectedSession.Id);
if (session is null) return;
var edited = await OnEditSession(session);
if (edited is null) return;
edited.GroupId = _groupId;
_sessions.Save(edited);
LoadSessions();
}
private bool CanEditSession() => !IsReadOnly && SelectedSession is not null;
partial void OnIsReadOnlyChanged(bool value) => EditSessionCommand.NotifyCanExecuteChanged();
[RelayCommand(CanExecute = nameof(CanQuickInput))]
private async Task QuickInput()
{
if (OnQuickInput is null) return;
await OnQuickInput(this);
if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
}
private bool CanQuickInput() => SelectedSession is not null && StudentRows.Count > 0;
[RelayCommand(CanExecute = nameof(CanQuickInput))]
private async Task StatusQuickInput()
{
if (OnStatusQuickInput is null) return;
await OnStatusQuickInput(this);
if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
}
[RelayCommand]
private async Task ComputeGrade()
{
if (OnComputeGrade is null) return;
await OnComputeGrade(this);
}
[RelayCommand]
private async Task OpenWizard()
{
if (OnOpenWizard is null) return;
await OnOpenWizard(this);
}
[RelayCommand]
private async Task ManageAspects()
{
if (IsReadOnly || OnManageAspects is null) return;
await OnManageAspects(this);
LoadAspects();
if (SelectedSession is not null) LoadGrid(SelectedSession.Id);
}
[RelayCommand]
private void DeleteSession()
{
if (SelectedSession is null) return;
_sessions.Delete(SelectedSession.Id);
LoadSessions();
}
public void SaveNote(Guid sessionId, Guid studentId, string note)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId);
if (entry is null) return;
entry.Note = note;
_entries.Save(entry);
}
private void SaveCompetencyRating(Guid sessionId, Guid studentId, string code, int? value)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry
{
SessionId = sessionId,
StudentId = studentId,
};
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code);
if (value is null)
{
if (existing is not null) entry.CompetencyRatings.Remove(existing);
}
else
{
if (existing is null) entry.CompetencyRatings.Add(new CompetencyRating { Code = code, Value = value.Value });
else existing.Value = value.Value;
}
_entries.Save(entry);
}
}
// ── Zeilendaten für das Bewertungsraster ─────────────────────────────────────
public partial class ParticipationStudentRow : ObservableObject
{
public Guid StudentId { get; }
public string Name { get; }
private readonly IReadOnlyList<AspectColumnDef> _aspectDefs;
public ObservableCollection<RatingCell> Cells { get; } = [];
public ObservableCollection<RatingCell> CompetencyCells { get; } = [];
[ObservableProperty] private HomeworkStatus? _homework;
[ObservableProperty] private AttendanceStatus? _attendance;
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
/// Abwesend im Sinne der Mitarbeitsbewertung: eine Bewertung ergibt für diese Stunde keinen
/// Sinn, unabhängig davon, ob die Abwesenheit entschuldigt ist oder noch geklärt werden muss.
public bool IsAbsent => Attendance is not null
and not AttendanceStatus.Present
and not AttendanceStatus.Late
and not AttendanceStatus.SignificantlyLate;
public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework);
public string HomeworkTooltip => HomeworkDisplay.Label(Homework);
public Action<Guid, string, int?>? OnRatingChanged { get; set; }
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
public Action<Guid, HomeworkStatus?>? HomeworkChangedCallback { get; set; }
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
List<AspectColumnDef> aspects, List<string> competencyCodes)
{
StudentId = id;
Name = name;
_aspectDefs = aspects;
_homework = HomeworkDisplay.Effective(entry);
_attendance = entry.Attendance;
foreach (var a in aspects)
{
var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key);
var cell = new RatingCell(id, a.Key, existing?.Value, a.ValueType, a.MaxPoints);
cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val);
Cells.Add(cell);
}
foreach (var code in competencyCodes)
{
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code);
var cell = new RatingCell(id, code, existing?.Value);
cell.OnChanged = (sid, key, val) => OnCompetencyRatingChanged?.Invoke(sid, key, val);
CompetencyCells.Add(cell);
}
}
// Liest aus Cells (per SetValue laufend aktuell gehalten), NICHT aus dem ursprünglichen
// ParticipationEntry-Snapshot: SetRating() schreibt nur in Cells + feuert OnRatingChanged
// (der Callback speichert über eine EIGENE, neu aus dem Repository geladene Entry-Instanz —
// der hier ursprünglich referenzierte Entry-Snapshot bekommt diese Änderung nie zu sehen).
public int? GetRating(string key) =>
Cells.FirstOrDefault(c => c.AspectKey == key)?.Value;
public void SetRating(string key, int? value)
{
var cell = Cells.FirstOrDefault(c => c.AspectKey == key);
cell?.SetValue(value);
OnRatingChanged?.Invoke(StudentId, key, value);
}
[RelayCommand]
private void ToggleHomework()
{
Homework = HomeworkDisplay.Next(Homework);
OnPropertyChanged(nameof(HomeworkSymbol));
OnPropertyChanged(nameof(HomeworkTooltip));
HomeworkChangedCallback?.Invoke(StudentId, Homework);
}
public void SetHomework(HomeworkStatus? value)
{
Homework = value;
OnPropertyChanged(nameof(HomeworkSymbol));
OnPropertyChanged(nameof(HomeworkTooltip));
HomeworkChangedCallback?.Invoke(StudentId, value);
}
[RelayCommand]
private void CycleAttendance()
{
Attendance = 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,
};
OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip));
OnPropertyChanged(nameof(IsAbsent));
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));
OnPropertyChanged(nameof(IsAbsent));
AttendanceChangedCallback?.Invoke(StudentId, value);
}
}
// ── Hausaufgaben-Anzeige ─────────────────────────────────────────────────────
public static class HomeworkDisplay
{
public static HomeworkStatus? Effective(ParticipationEntry entry) =>
entry.Homework ?? (entry.HomeworkMissing ? HomeworkStatus.MissingOpen : null);
public static HomeworkStatus? Next(HomeworkStatus? status) => status switch
{
null => HomeworkStatus.Completed,
HomeworkStatus.Completed => HomeworkStatus.PartiallyCompleted,
HomeworkStatus.PartiallyCompleted => HomeworkStatus.PartialSubmittedLate,
HomeworkStatus.PartialSubmittedLate => HomeworkStatus.PartialMissingOverdue,
HomeworkStatus.PartialMissingOverdue => HomeworkStatus.MissingOpen,
HomeworkStatus.MissingOpen => HomeworkStatus.MissingOverdue,
HomeworkStatus.MissingOverdue => HomeworkStatus.SubmittedLate,
HomeworkStatus.SubmittedLate => null,
_ => null,
};
public static string Label(HomeworkStatus? status) => status switch
{
null => "Keine Hausaufgabe aufgegeben",
HomeworkStatus.Completed => "Hausaufgabe gemacht",
HomeworkStatus.PartiallyCompleted => "Teilweise angefertigt Rest offen",
HomeworkStatus.PartialSubmittedLate => "Teilweise angefertigt Rest nachgereicht",
HomeworkStatus.PartialMissingOverdue => "Teilweise angefertigt Rest nicht nachgereicht",
HomeworkStatus.MissingOpen => "Nicht gemacht Nachreichen offen",
HomeworkStatus.MissingOverdue => "Nicht gemacht nicht mehr nachgereicht",
HomeworkStatus.SubmittedLate => "Hausaufgabe nachgereicht",
_ => "Keine Hausaufgabe aufgegeben",
};
public static string Symbol(HomeworkStatus? status) => status switch
{
null => "·",
HomeworkStatus.Completed => "✓",
HomeworkStatus.PartiallyCompleted => "◐",
HomeworkStatus.PartialSubmittedLate => "◕",
HomeworkStatus.PartialMissingOverdue => "◒",
HomeworkStatus.MissingOpen => "!",
HomeworkStatus.MissingOverdue => "✕",
HomeworkStatus.SubmittedLate => "↺",
_ => "·",
};
public static string Color(HomeworkStatus? status) => status switch
{
HomeworkStatus.Completed => "#2E9D57",
HomeworkStatus.PartiallyCompleted => "#D98200",
HomeworkStatus.PartialSubmittedLate => "#7F77DD",
HomeworkStatus.PartialMissingOverdue => "#D64545",
HomeworkStatus.MissingOpen => "#D98200",
HomeworkStatus.MissingOverdue => "#D64545",
HomeworkStatus.SubmittedLate => "#7F77DD",
_ => "",
};
public static bool CountsAsMissing(HomeworkStatus? status) => status is
HomeworkStatus.MissingOpen or
HomeworkStatus.MissingOverdue or
HomeworkStatus.PartiallyCompleted or
HomeworkStatus.PartialMissingOverdue;
}
// ── Anwesenheits-Anzeige ──────────────────────────────────────────────────────
public static class AttendanceDisplay
{
public static string Label(AttendanceStatus? s) => s switch
{
null => "Noch nicht kontrolliert",
AttendanceStatus.Present => "Anwesend",
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
AttendanceStatus.Excused => "Krank, entschuldigt",
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
AttendanceStatus.Truant => "Geschwänzt",
AttendanceStatus.OtherSchoolEvent => "Andere Schulveranstaltung",
AttendanceStatus.Late => "Verspätet",
AttendanceStatus.SignificantlyLate => "Erheblich verspätet",
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
AttendanceStatus.LearningIsland => "Lerninsel",
AttendanceStatus.Suspended => "Suspendiert",
_ => "Anwesend",
};
public static string ShortLabel(AttendanceStatus? s) => s switch
{
null => "",
AttendanceStatus.Present => "✓",
AttendanceStatus.ExcusePending => "?",
AttendanceStatus.Excused => "⊘",
AttendanceStatus.Unexcused => "!",
AttendanceStatus.Truant => "✕",
AttendanceStatus.OtherSchoolEvent => "◇",
AttendanceStatus.Late => "V",
AttendanceStatus.SignificantlyLate => "V!",
AttendanceStatus.LeftDuringClass => "A",
AttendanceStatus.LearningIsland => "L",
AttendanceStatus.Suspended => "S",
_ => "",
};
public static string Color(AttendanceStatus? s) => s switch
{
AttendanceStatus.Present => "#2E9D57",
AttendanceStatus.ExcusePending => "#D98200",
AttendanceStatus.Excused => "#4C86A8",
AttendanceStatus.Unexcused => "#D96C00",
AttendanceStatus.Truant => "#D64545",
AttendanceStatus.OtherSchoolEvent => "#5277C3",
AttendanceStatus.Late => "#D98200",
AttendanceStatus.SignificantlyLate => "#D96C00",
AttendanceStatus.LeftDuringClass => "#D64545",
AttendanceStatus.LearningIsland => "#5277C3",
AttendanceStatus.Suspended => "#6B6576",
_ => "",
};
}
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
public partial class RatingCell : ObservableObject
{
public Guid StudentId { get; }
public string AspectKey { get; }
public AspectValueType Type { get; }
public int MaxPoints { get; }
[ObservableProperty] private int? _value;
[ObservableProperty] private string _displayLabel = "";
public Action<Guid, string, int?>? OnChanged { get; set; }
public RatingCell(Guid studentId, string key, int? value,
AspectValueType type = AspectValueType.Scale5, int maxPoints = 5)
{
StudentId = studentId;
AspectKey = key;
Type = type;
MaxPoints = maxPoints;
_value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value;
UpdateLabel();
}
public void SetValue(int? value)
{
Value = Type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, MaxPoints) : value;
UpdateLabel();
OnChanged?.Invoke(StudentId, AspectKey, Value);
}
[RelayCommand]
private void CycleUp()
{
if (Type == AspectValueType.Points)
{
SetValue(Value is null ? 0 : Math.Min(MaxPoints, Value.Value + 1));
return;
}
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = Value is null ? -1 : steps.IndexOf(Value.Value);
SetValue(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
}
[RelayCommand]
private void CycleDown()
{
if (Type == AspectValueType.Points)
{
SetValue(Value is null ? MaxPoints : Math.Max(0, Value.Value - 1));
return;
}
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = Value is null ? steps.Count : steps.IndexOf(Value.Value);
SetValue(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
}
[RelayCommand]
private void Clear() => SetValue(null);
private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
}
// ── Kompetenz-Tags ────────────────────────────────────────────────────────────
public class CompetencyTagGroup(string name, string code)
{
public string Name { get; } = name;
public string Code { get; } = code;
public string DisplayName { get; } = string.IsNullOrEmpty(code) ? name : $"{name} ({code})";
public List<CompetencyTag> Items { get; } = [];
}
public partial class CompetencyTag : ObservableObject
{
public string Code { get; }
public string Description { get; }
public string Display { get; }
[ObservableProperty] private bool _isSelected;
public Action<string, bool>? OnChanged { get; set; }
public CompetencyTag(string code, string description, bool isSelected)
{
Code = code;
Description = description;
Display = string.IsNullOrEmpty(code) ? description : $"[{code}] {description}";
_isSelected = isSelected;
}
partial void OnIsSelectedChanged(bool value) => OnChanged?.Invoke(Code, value);
}
// ── Hilfsklassen ──────────────────────────────────────────────────────────────
public class AspectColumnDef
{
public string Key { get; }
public string Label { get; }
public AspectValueType ValueType { get; }
public int MaxPoints { get; }
public AspectColumnDef(ParticipationAspect a)
{
Key = a.Key; Label = a.Label; ValueType = a.ValueType; MaxPoints = a.MaxPoints;
}
}
public class ParticipationSessionItem
{
public Guid Id { get; }
public string Display { get; }
public string Comment { get; }
public DateOnly Date { get; }
public ParticipationSessionItem(ParticipationSession s)
{
Id = s.Id;
Date = s.Date;
Comment = s.Comment ?? "";
Display = s.Comment is { Length: > 0 }
? $"{s.Date:dd.MM.yyyy} {s.Comment}"
: s.Date.ToString("dd.MM.yyyy");
}
}
// ── Dialog: Sitzung anlegen ───────────────────────────────────────────────────
public partial class AddSessionDialogViewModel : ObservableObject
{
private readonly ParticipationSession? _editingSession;
[ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _comment = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _dateTextError = "";
public ParticipationSession? Result { get; private set; }
public bool IsEditing => _editingSession is not null;
public string DialogTitle => IsEditing ? "Bewertungszeitpunkt bearbeiten" : "Bewertungszeitpunkt anlegen";
public string SaveButtonText => IsEditing ? "Speichern" : "Anlegen";
public AddSessionDialogViewModel(ParticipationSession? editingSession = null)
{
_editingSession = editingSession;
if (editingSession is null) return;
Date = editingSession.Date;
DateText = editingSession.Date.ToString("dd.MM.yyyy");
Comment = editingSession.Comment ?? "";
}
[RelayCommand]
private void Save()
{
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
{
DateTextError = "Format TT.MM.JJJJ.";
return;
}
DateTextError = "";
var session = _editingSession ?? new ParticipationSession();
session.Date = date;
session.Comment = Comment.Trim();
Result = session;
}
}
// ── Dialog: Schnelleingabe ────────────────────────────────────────────────────
public partial class QuickInputViewModel : ObservableObject
{
private readonly List<ParticipationStudentRow> _rows;
private readonly List<AspectColumnDef> _aspects;
[ObservableProperty] private int _studentIndex;
[ObservableProperty] private int _aspectIndex;
[ObservableProperty] private string _studentName = "";
[ObservableProperty] private string _currentAspectLabel = "";
[ObservableProperty] private string _currentValueLabel = "";
[ObservableProperty] private string _progressText = "";
[ObservableProperty] private bool _currentStudentIsAbsent;
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
/// gültige Zifferntasten als Scale5, Points nutzt 0-9 als direkte Punkteingabe statt fester
/// Stufen. Ändert sich mit AspectIndex, siehe OnAspectIndexChanged.
public string HotkeyLegend
{
get
{
var ratingHint = CurrentAspectType() switch
{
AspectValueType.Scale5 => "15 bewerten",
AspectValueType.Scale3 => "13 bewerten",
AspectValueType.Binary => "1 Nein / 2 Ja",
AspectValueType.Points => $"09 Punkte eingeben (bis {CurrentAspectMaxPoints()})",
_ => "15 bewerten",
};
return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " +
"Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/ anpassen · Esc schließen";
}
}
public QuickInputViewModel(List<ParticipationStudentRow> rows, List<AspectColumnDef> aspects)
{
_rows = rows;
_aspects = aspects;
if (rows.Any()) ShowStudent(0);
}
partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend));
partial void OnCurrentStudentIsAbsentChanged(bool value) => OnPropertyChanged(nameof(CurrentStudentContentOpacity));
private AspectValueType CurrentAspectType() =>
_aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType;
private int CurrentAspectMaxPoints() =>
_aspects.Count == 0 ? 5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].MaxPoints;
private void ShowStudent(int index)
{
if (index < 0 || index >= _rows.Count) return;
StudentIndex = index;
var row = _rows[index];
StudentName = row.Name;
ProgressText = $"{index + 1} / {_rows.Count}";
CurrentStudentIsAbsent = row.IsAbsent;
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
AspectRows.Clear();
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
{
var val = row.GetRating(a.Key);
AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex, a.ValueType));
}
UpdateCurrentAspect();
}
private void UpdateCurrentAspect()
{
if (!_aspects.Any()) return;
var safeIdx = Math.Clamp(AspectIndex, 0, _aspects.Count - 1);
CurrentAspectLabel = _aspects[safeIdx].Label;
var row = AspectRows.ElementAtOrDefault(safeIdx);
CurrentValueLabel = row?.DisplayLabel ?? "";
foreach (var r in AspectRows) r.IsActive = r.Index == safeIdx;
}
public void SetRatingByNumber(int num)
{
var type = CurrentAspectType();
if (type == AspectValueType.Points)
{
ApplyRating(Math.Clamp(num, 0, CurrentAspectMaxPoints()));
return;
}
var steps = ParticipationRatingScale.Steps(type);
if (num < 1 || num > steps.Count) return;
ApplyRating(steps[num - 1].Value);
}
public void IncrementRating()
{
var cell = GetCurrentCell();
if (cell is null) return;
var type = CurrentAspectType();
if (type == AspectValueType.Points)
{
var max = CurrentAspectMaxPoints();
ApplyRating(cell.Value is null ? 0 : Math.Min(max, cell.Value.Value + 1));
return;
}
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = cell.Value is null ? -1 : steps.IndexOf(cell.Value.Value);
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
}
public void DecrementRating()
{
var cell = GetCurrentCell();
if (cell is null) return;
var type = CurrentAspectType();
if (type == AspectValueType.Points)
{
ApplyRating(cell.Value is null ? CurrentAspectMaxPoints() : Math.Max(0, cell.Value.Value - 1));
return;
}
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
if (steps.Count == 0) return;
var idx = cell.Value is null ? steps.Count : steps.IndexOf(cell.Value.Value);
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
}
private void ApplyRating(int val)
{
if (_rows.Count == 0 || !_aspects.Any()) return;
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
_rows[StudentIndex].SetRating(key, val);
var row = AspectRows.ElementAtOrDefault(AspectIndex);
if (row is not null)
{
row.Value = val;
row.UpdateLabel();
}
CurrentValueLabel = ParticipationRatingScale.DisplayLabel(CurrentAspectType(), val);
}
public void SelectAspect(int index)
{
if (index < 0 || index >= _aspects.Count) return;
AspectIndex = index;
UpdateCurrentAspect();
}
public void NextAspect()
{
AspectIndex = (AspectIndex + 1) % _aspects.Count;
UpdateCurrentAspect();
}
public void PreviousAspect()
{
AspectIndex = (AspectIndex - 1 + _aspects.Count) % _aspects.Count;
UpdateCurrentAspect();
}
public void NextStudent()
{
if (StudentIndex >= _rows.Count - 1) return;
AspectIndex = 0;
ShowStudent(StudentIndex + 1);
}
public void PreviousStudent()
{
if (StudentIndex <= 0) return;
AspectIndex = 0;
ShowStudent(StudentIndex - 1);
}
public bool IsLastStudent => StudentIndex >= _rows.Count - 1;
private RatingCell? GetCurrentCell()
{
if (_rows.Count == 0 || !_aspects.Any()) return null;
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key);
}
}
public partial class QuickAspectRow : ObservableObject
{
public int Index { get; }
public string Label { get; }
public AspectValueType Type { get; }
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _displayLabel = "";
public int? Value { get; set; }
public QuickAspectRow(int index, string label, int? value, bool isActive, AspectValueType type = AspectValueType.Scale5)
{
Index = index;
Label = label;
Value = value;
IsActive = isActive;
Type = type;
UpdateLabel();
}
public void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
}
// ── Schnelleingabe Anwesenheit / Hausaufgaben ────────────────────────────────
public partial class AttendanceHomeworkQuickInputViewModel : ObservableObject
{
public List<ParticipationStudentRow> Rows { get; }
public string SessionLabel { get; }
[ObservableProperty] private ParticipationStudentRow? _selectedRow;
[ObservableProperty] private bool _advanceAfterInput = true;
public AttendanceHomeworkQuickInputViewModel(
IEnumerable<ParticipationStudentRow> rows, string sessionLabel)
{
Rows = rows.ToList();
SessionLabel = sessionLabel;
SelectedRow = Rows.FirstOrDefault();
}
public void ApplyAttendance(ParticipationStudentRow row, AttendanceStatus? status)
{
SelectedRow = row;
row.SetAttendance(status);
AdvanceIfRequested();
}
public void ApplyHomework(ParticipationStudentRow row, HomeworkStatus? status)
{
SelectedRow = row;
row.SetHomework(status);
AdvanceIfRequested();
}
public void MoveSelection(int delta)
{
if (Rows.Count == 0) return;
var current = SelectedRow is null ? 0 : Rows.IndexOf(SelectedRow);
SelectedRow = Rows[Math.Clamp(current + delta, 0, Rows.Count - 1)];
}
private void AdvanceIfRequested()
{
if (AdvanceAfterInput) MoveSelection(1);
}
}
// ── Aspekte verwalten (3.1) ──────────────────────────────────────────────────
/// Deutsche Anzeige für <see cref="AspectValueType"/>, gleiches Muster wie NiveauDisplay/
/// GradeCategoryDisplay — ComboBox bindet an das Enum sonst über ToString() (englische Namen).
public static class AspectValueTypeDisplay
{
public static string[] Options { get; } = ["Skala 15", "Skala 13", "Ja/Nein", "Punkte"];
public static string ToName(AspectValueType t) => t switch
{
AspectValueType.Scale5 => "Skala 15",
AspectValueType.Scale3 => "Skala 13",
AspectValueType.Binary => "Ja/Nein",
AspectValueType.Points => "Punkte",
_ => "Skala 15",
};
public static AspectValueType FromName(string? name) => name switch
{
"Skala 13" => AspectValueType.Scale3,
"Ja/Nein" => AspectValueType.Binary,
"Punkte" => AspectValueType.Points,
_ => AspectValueType.Scale5,
};
}
/// Eine Zeile in der Aspekt-Verwaltung (3.1.1/3.1.2/3.1.4). Label/Gewichtung/Aktiv-Status
/// speichern bei jeder Änderung sofort (gleiches Muster wie AspectWeightItem in 3.2.1) —
/// "Schlüssel" ist bewusst nur beim Neuanlegen editierbar, nicht nachträglich in der Zeile: er
/// ist die Verknüpfung zu historischen AspectRating-Einträgen (per Key, nicht per Id), ein
/// nachträgliches Umbenennen würde alte Bewertungen dieses Aspekts unauffindbar machen.
public partial class AspectEditItem : ObservableObject
{
private readonly ParticipationAspect _aspect;
private readonly IParticipationAspectRepository _repo;
public Guid Id => _aspect.Id;
public string Key => _aspect.Key;
[ObservableProperty] private string _label;
[ObservableProperty] private string _valueTypeName;
[ObservableProperty] private int _maxPoints;
[ObservableProperty] private double _weight;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _errorMessage = "";
public string[] ValueTypeOptions => AspectValueTypeDisplay.Options;
// Nur bei ValueType "Punkte" relevant (3.1.3) — MaxPoints-Feld nur dann in der Verwaltung
// anzeigen, siehe ParticipationAspectsDialog.axaml.
public bool IsPointsType => AspectValueTypeDisplay.FromName(ValueTypeName) == AspectValueType.Points;
public Action<AspectEditItem>? OnDelete { get; set; }
public IRelayCommand DeleteCommand { get; }
public IRelayCommand MoveUpCommand { get; }
public IRelayCommand MoveDownCommand { get; }
public AspectEditItem(ParticipationAspect aspect, IParticipationAspectRepository repo,
Action<AspectEditItem>? onMoveUp = null, Action<AspectEditItem>? onMoveDown = null)
{
_aspect = aspect; _repo = repo;
_label = aspect.Label;
_valueTypeName = AspectValueTypeDisplay.ToName(aspect.ValueType);
_maxPoints = aspect.MaxPoints;
_weight = aspect.Weight;
_isActive = aspect.IsActive;
DeleteCommand = new RelayCommand(() => OnDelete?.Invoke(this));
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
}
private bool _canMoveUp;
private bool _canMoveDown;
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
{
_canMoveUp = canMoveUp; _canMoveDown = canMoveDown;
MoveUpCommand.NotifyCanExecuteChanged();
MoveDownCommand.NotifyCanExecuteChanged();
}
internal void SetSortOrder(int sortOrder)
{
if (_aspect.SortOrder == sortOrder) return;
_aspect.SortOrder = sortOrder;
_repo.Save(_aspect);
}
private void TrySave(Action apply)
{
ErrorMessage = "";
var before = (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive);
apply();
try { _repo.Save(_aspect); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
// Zurückrollen, damit Feld und gespeicherter Stand nicht auseinanderlaufen. Bewusst
// die Backing-Felder direkt gesetzt statt der generierten Properties — über die
// Properties würde erneut On{X}Changed feuern und damit wieder TrySave aufrufen.
#pragma warning disable MVVMTK0034
(_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive) = before;
_label = _aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(_aspect.ValueType);
_maxPoints = _aspect.MaxPoints; _weight = _aspect.Weight; _isActive = _aspect.IsActive;
#pragma warning restore MVVMTK0034
OnPropertyChanged(nameof(Label)); OnPropertyChanged(nameof(ValueTypeName));
OnPropertyChanged(nameof(MaxPoints)); OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive));
ErrorMessage = ex.Message;
}
}
partial void OnLabelChanged(string value) => TrySave(() => _aspect.Label = value);
partial void OnValueTypeNameChanged(string value)
{
TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value));
OnPropertyChanged(nameof(IsPointsType));
}
partial void OnMaxPointsChanged(int value) => TrySave(() => _aspect.MaxPoints = value);
partial void OnWeightChanged(double value) => TrySave(() => _aspect.Weight = value);
partial void OnIsActiveChanged(bool value) => TrySave(() => _aspect.IsActive = value);
}
/// Verwaltungsdialog für ParticipationAspect (3.1.1/3.1.2/3.1.4), pro Gruppe. Verwaltet bewusst
/// nur die gruppenspezifischen Aspekte (GroupId = diese Gruppe), nicht den globalen
/// Standardkatalog (GroupId = null) — der wird bislang nirgends befüllt (siehe
/// DefaultParticipationAspects, reiner In-Memory-Fallback ohne UI) und eine Änderung dort würde
/// sofort alle Gruppen betreffen; das wäre ein eigener, separat zu entscheidender Schritt.
public partial class ParticipationAspectsDialogViewModel : ObservableObject
{
private readonly IParticipationAspectRepository _repo;
private readonly Guid _groupId;
[ObservableProperty] private string _newKey = "";
[ObservableProperty] private string _newLabel = "";
[ObservableProperty] private string _newValueTypeName = AspectValueTypeDisplay.Options[0];
[ObservableProperty] private int _newMaxPoints = 5;
[ObservableProperty] private string _newAspectError = "";
public string[] ValueTypeOptions => AspectValueTypeDisplay.Options;
public bool IsNewPointsType => AspectValueTypeDisplay.FromName(NewValueTypeName) == AspectValueType.Points;
public ObservableCollection<AspectEditItem> Aspects { get; } = [];
public Func<AspectEditItem, Task<bool>>? OnConfirmDelete { get; set; }
public ParticipationAspectsDialogViewModel(IParticipationAspectRepository repo, Guid groupId)
{
_repo = repo; _groupId = groupId;
Load();
}
partial void OnNewValueTypeNameChanged(string value) => OnPropertyChanged(nameof(IsNewPointsType));
private void Load()
{
Aspects.Clear();
foreach (var a in _repo.GetAllByGroup(_groupId))
Aspects.Add(CreateItem(a));
RefreshMoveState();
}
private AspectEditItem CreateItem(ParticipationAspect a) =>
new(a, _repo, item => MoveAspect(item, -1), item => MoveAspect(item, 1)) { OnDelete = DeleteAspectAsync };
private void MoveAspect(AspectEditItem item, int offset)
{
var oldIndex = Aspects.IndexOf(item);
var newIndex = oldIndex + offset;
if (oldIndex < 0 || newIndex < 0 || newIndex >= Aspects.Count) return;
Aspects.Move(oldIndex, newIndex);
for (var i = 0; i < Aspects.Count; i++) Aspects[i].SetSortOrder(i);
RefreshMoveState();
}
private void RefreshMoveState()
{
for (var i = 0; i < Aspects.Count; i++)
Aspects[i].SetMoveState(i > 0, i < Aspects.Count - 1);
}
[RelayCommand]
private void AddAspect()
{
NewAspectError = "";
if (string.IsNullOrWhiteSpace(NewKey)) { NewAspectError = "Schlüssel erforderlich."; return; }
if (string.IsNullOrWhiteSpace(NewLabel)) { NewAspectError = "Bezeichnung erforderlich."; return; }
var aspect = new ParticipationAspect
{
GroupId = _groupId,
Key = NewKey.Trim(),
Label = NewLabel.Trim(),
ValueType = AspectValueTypeDisplay.FromName(NewValueTypeName),
MaxPoints = NewMaxPoints,
SortOrder = Aspects.Count,
};
try { _repo.Save(aspect); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
NewAspectError = ex.Message;
return;
}
Aspects.Add(CreateItem(aspect));
RefreshMoveState();
NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; NewMaxPoints = 5;
}
private async void DeleteAspectAsync(AspectEditItem item)
{
if (OnConfirmDelete is not null && !await OnConfirmDelete(item)) return;
_repo.Delete(item.Id);
Aspects.Remove(item);
for (var i = 0; i < Aspects.Count; i++) Aspects[i].SetSortOrder(i);
RefreshMoveState();
}
}