Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs
T
adminandClaude Sonnet 5 455f7765e4
CI / build-and-test (push) Canceled after 0s
feat: Kompetenz-Bewertung je Schüler/Sitzung aus Mitarbeit-Durchschnitt schätzen
Manuelle Einzelerfassung je Kompetenz war im Alltag zu aufwendig (Nutzer-Feedback). Neue
ParticipationCompetencyEstimate mittelt die bereits erfassten Aspekt-Bewertungen der Sitzung
(normiert auf dieselbe -2..+2-Achse) und rundet auf den nächsten Schritt; die Tagesflagge wirkt
symmetrisch (Spitzentag hebt, Schlaftag/Schlechter Tag senken gleichermaßen).

Die Schätzung wird nie automatisch gespeichert - sie erscheint nur als abgeschwächt dargestellter
Zellenwert (RatingCell.IsEstimated), solange keine explizite Bewertung existiert. Ein Klick setzt
den Wert bewusst und persistiert ihn wie bisher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 01:57:45 +02:00

1350 lines
56 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 readonly ICompetencyCatalogVersionRepository _competencyVersions;
private Guid _groupId;
private string _schoolYear = "";
private Guid? _subjectId;
private int _gradeLevel;
private Guid _competencyCatalogVersionId;
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<CompetencyReference> ActiveCompetencies { 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 Func<ParticipationSessionItem, int, Task<bool>>? OnConfirmDeleteSession { get; set; }
public ParticipationTabViewModel(
IParticipationSessionRepository sessions,
IParticipationRepository entries,
IParticipationAspectRepository aspects,
IStudentRepository students,
IGroupMembershipRepository memberships,
IGroupRepository groups,
ICompetencyDomainRepository competencyDomains,
ICompetencyCatalogVersionRepository competencyVersions)
{
_sessions = sessions; _entries = entries;
_aspects = aspects; _students = students;
_memberships = memberships; _groups = groups;
_competencyDomains = competencyDomains;
_competencyVersions = competencyVersions;
}
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;
_competencyCatalogVersionId = CompetencyCatalogVersionResolver.Resolve(
_competencyVersions, _subjectId, group?.CompetencyCatalogVersionId);
_gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6;
GroupLabel = group?.Name ?? "";
HasCompetencyCatalog = _subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId).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();
DeleteSessionCommand.NotifyCanExecuteChanged();
if (value is null)
{
StudentRows.Clear();
CompetencyTagGroups.Clear();
ActiveCompetencies = [];
QuickInputCommand.NotifyCanExecuteChanged();
StatusQuickInputCommand.NotifyCanExecuteChanged();
RebuildColumnsSignal++;
return;
}
LoadCompetencyTags(value.Id); // sets ActiveCompetencies first
LoadGrid(value.Id); // uses ActiveCompetencies, 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(), ActiveCompetencies);
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);
row.DayHighlightChangedCallback = (sid, val) => SaveDayHighlight(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);
}
private void SaveDayHighlight(Guid sessionId, Guid studentId, DayHighlightKind? value)
{
if (IsReadOnly) return;
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.DayHighlight = 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);
ActiveCompetencies = session?.Competencies?.ToList() ?? [];
if (!_subjectId.HasValue) return;
var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId);
foreach (var group in CompetencyTagGroupBuilder.Build(catalog, ActiveCompetencies, OnTagToggled))
CompetencyTagGroups.Add(group);
}
private void OnTagToggled(CompetencyTag tag)
{
if (SelectedSession is null) return;
var session = _sessions.GetById(SelectedSession.Id);
if (session is null) return;
if (tag.IsSelected)
{
if (!session.Competencies.Any(r => r.ItemId == tag.ItemId))
session.Competencies.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description });
}
else
{
session.Competencies.RemoveAll(r => r.ItemId == tag.ItemId);
}
_sessions.Save(session);
ActiveCompetencies = session.Competencies.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();
DeleteSessionCommand.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(CanExecute = nameof(CanDeleteSession))]
private async Task DeleteSession()
{
if (SelectedSession is null) return;
var entryCount = _entries.GetBySession(SelectedSession.Id).Count;
if (OnConfirmDeleteSession is not null
&& !await OnConfirmDeleteSession(SelectedSession, entryCount)) return;
_sessions.Delete(SelectedSession.Id);
LoadSessions();
}
private bool CanDeleteSession() => !IsReadOnly && SelectedSession is not null;
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);
}
// "key" ist der Kompetenz-ItemId als String (siehe ParticipationStudentRow/RatingCell) - reiner
// In-Memory-Transport zwischen Zelle und Save-Callback, nie persistiert.
private void SaveCompetencyRating(Guid sessionId, Guid studentId, string key, int? value)
{
var itemId = Guid.Parse(key);
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry
{
SessionId = sessionId,
StudentId = studentId,
};
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.ItemId == itemId);
if (value is null)
{
if (existing is not null) entry.CompetencyRatings.Remove(existing);
}
else
{
if (existing is null) entry.CompetencyRatings.Add(new CompetencyRating { ItemId = itemId, 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;
[ObservableProperty] private DayHighlightKind? _dayHighlight;
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 Action<Guid, DayHighlightKind?>? DayHighlightChangedCallback { get; set; }
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
List<AspectColumnDef> aspects, List<CompetencyReference> competencies)
{
StudentId = id;
Name = name;
_aspectDefs = aspects;
_homework = HomeworkDisplay.Effective(entry);
_attendance = entry.Attendance;
_dayHighlight = entry.DayHighlight;
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);
}
// AspectKey trägt hier den Kompetenz-ItemId als String - reiner In-Memory-Transport
// zwischen Zelle und Save-Callback (siehe SaveCompetencyRating), nie persistiert.
// Ohne eigene Bewertung wird aus dem Mitarbeit-Durchschnitt der Sitzung geschätzt
// (Nutzer-Feedback: manuelle Einzelerfassung je Kompetenz ist im Alltag zu aufwendig) -
// die Schätzung wird NIE automatisch gespeichert, nur angezeigt (siehe RatingCell.IsEstimated);
// ein Klick auf die Zelle setzt den Wert bewusst und persistiert ihn wie gehabt.
var estimate = ParticipationCompetencyEstimate.Estimate(entry.Ratings, aspects, entry.DayHighlight);
foreach (var competency in competencies)
{
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.ItemId == competency.ItemId);
var cell = new RatingCell(id, competency.ItemId.ToString("D"), existing?.Value ?? estimate,
isEstimated: existing is null && estimate is not null);
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);
}
public void SetDayHighlight(DayHighlightKind? value)
{
DayHighlight = value;
DayHighlightChangedCallback?.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",
_ => "",
};
}
// ── Tagesflagge (Nutzer-Feedback) ──────────────────────────────────────────────
public static class DayHighlightDisplay
{
public static string Symbol(DayHighlightKind? kind) => kind switch
{
DayHighlightKind.Standout => "👑",
DayHighlightKind.Sleepy => "😴",
DayHighlightKind.Rough => "⚡",
_ => "",
};
public static string Label(DayHighlightKind? kind) => kind switch
{
DayHighlightKind.Standout => "Spitzentag",
DayHighlightKind.Sleepy => "Schlaftag",
DayHighlightKind.Rough => "Schlechter Tag",
_ => "Keine Markierung",
};
}
// ── 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 = "";
/// <summary>true, solange Value aus ParticipationCompetencyEstimate geschätzt und noch nie
/// explizit vom Nutzer gesetzt wurde (siehe ParticipationStudentRow) - steuert nur die Anzeige
/// (reduzierte Deckkraft), nichts davon ist in ParticipationEntry.CompetencyRatings persistiert.
/// Wird bei jedem SetValue-Aufruf unwiderruflich auf false gesetzt, auch wenn der Nutzer
/// zufällig genau den geschätzten Wert bestätigt - ab dann zählt es als bewusste Entscheidung.</summary>
[ObservableProperty] private bool _isEstimated;
public Action<Guid, string, int?>? OnChanged { get; set; }
public RatingCell(Guid studentId, string key, int? value,
AspectValueType type = AspectValueType.Scale5, int maxPoints = 5, bool isEstimated = false)
{
StudentId = studentId;
AspectKey = key;
Type = type;
MaxPoints = maxPoints;
_value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value;
_isEstimated = isEstimated;
UpdateLabel();
}
public void SetValue(int? value)
{
// Reihenfolge wichtig: IsEstimated zuerst, damit ein auf Value lauschender Zellen-Renderer
// (siehe ParticipationTabView.axaml.cs) beim Auswerten von Value bereits den korrekten
// "bestätigt statt geschätzt"-Zustand sieht.
IsEstimated = false;
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);
}
// ── 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;
}
}
/// <summary>Nutzer-Feedback: Kompetenzen je Sitzung einzeln von Hand zu bewerten ist im Alltag zu
/// aufwendig - eine Schätzung aus dem allgemeinen Mitarbeit-Durchschnitt der Sitzung (auf derselben
/// -2..+2-Achse wie die Kompetenz-Bewertung selbst, siehe ParticipationRatingScale.Normalize) ersetzt
/// die manuelle Erfassung im Regelfall; die Lehrkraft korrigiert nur noch die Ausnahmen von Hand.
/// Die Tagesflagge wirkt bewusst symmetrisch (Nutzer-Entscheidung): Spitzentag hebt, Schlaftag und
/// Schlechter Tag senken die Schätzung gleichermaßen, obwohl "Schlaftag" strenggenommen eher die
/// Tagesform als die fachliche Kompetenz beschreibt.</summary>
public static class ParticipationCompetencyEstimate
{
private const double DayHighlightAdjustment = 1.0;
public static int? Estimate(List<AspectRating> ratings, IReadOnlyList<AspectColumnDef> aspects, DayHighlightKind? dayHighlight)
{
var normalized = ratings
.Select(r => (Rating: r, Def: aspects.FirstOrDefault(a => a.Key == r.Key)))
.Where(x => x.Def is not null)
.Select(x => ParticipationRatingScale.Normalize(x.Def!.ValueType, x.Rating.Value, x.Def.MaxPoints))
.ToList();
if (normalized.Count == 0) return null;
var adjustment = dayHighlight switch
{
DayHighlightKind.Standout => DayHighlightAdjustment,
DayHighlightKind.Sleepy or DayHighlightKind.Rough => -DayHighlightAdjustment,
_ => 0,
};
var estimate = (int)Math.Round(normalized.Average() + adjustment, MidpointRounding.AwayFromZero);
return Math.Clamp(estimate, -2, 2);
}
}
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 = "";
[ObservableProperty] private DayHighlightKind? _currentStudentDayHighlight;
/// 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;
/// Tagesflagge (Nutzer-Feedback): Fallback für Kurse ohne Sitzplan — im Sitzplatz-Dialog gibt
/// es dieselbe Auswahl bereits über SeatAssessmentViewModel.DayHighlightChoices.
public string CurrentStudentDayHighlightSymbol => DayHighlightDisplay.Symbol(CurrentStudentDayHighlight);
public string CurrentStudentDayHighlightLabel => DayHighlightDisplay.Label(CurrentStudentDayHighlight);
public void SetDayHighlight(DayHighlightKind? value)
{
if (_rows.Count == 0) return;
_rows[StudentIndex].SetDayHighlight(value);
CurrentStudentDayHighlight = value;
}
partial void OnCurrentStudentDayHighlightChanged(DayHighlightKind? value)
{
OnPropertyChanged(nameof(CurrentStudentDayHighlightSymbol));
OnPropertyChanged(nameof(CurrentStudentDayHighlightLabel));
}
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 => "1–5 bewerten",
AspectValueType.Scale3 => "1–3 bewerten",
AspectValueType.Binary => "1 Nein / 2 Ja",
AspectValueType.Points => $"0–9 Punkte eingeben (bis {CurrentAspectMaxPoints()})",
_ => "1–5 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 · Entf nicht bewertet · " +
"⇧1/2/3 Tagesflagge, ⇧0 löschen · 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;
CurrentStudentDayHighlight = row.DayHighlight;
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)]);
}
/// Nutzer-Feedback: eine aus Versehen gesetzte Bewertung (z.B. Qualität, obwohl der Schüler
/// sich nie gemeldet hat) muss sich wieder auf "nicht bewertet" zurücksetzen lassen, ohne
/// einen inhaltlich falschen Wert stehen lassen zu müssen. Bewusst eine eigene Aktion statt
/// über 0/1 zu laufen — 0 ("--") ist bei Quantität ein echter, beobachteter Wert ("hat sich
/// nicht gemeldet"), kein Platzhalter für "nicht ermittelt".
public void ClearRating() => ApplyRating(null);
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 1–5", "Skala 1–3", "Ja/Nein", "Punkte"];
public static string ToName(AspectValueType t) => t switch
{
AspectValueType.Scale5 => "Skala 1–5",
AspectValueType.Scale3 => "Skala 1–3",
AspectValueType.Binary => "Ja/Nein",
AspectValueType.Points => "Punkte",
_ => "Skala 1–5",
};
public static AspectValueType FromName(string? name) => name switch
{
"Skala 1–3" => 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();
}
}