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>
1169 lines
46 KiB
C#
1169 lines
46 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.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<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()
|
||
{
|
||
Sessions.Clear();
|
||
foreach (var s in _sessions.GetByGroup(_groupId))
|
||
Sessions.Add(new ParticipationSessionItem(s));
|
||
if (SelectedSession is null && Sessions.Any())
|
||
SelectedSession = Sessions[0];
|
||
}
|
||
|
||
partial void OnSelectedSessionChanged(ParticipationSessionItem? value)
|
||
{
|
||
OnPropertyChanged(nameof(SelectedSessionDisplay));
|
||
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(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 ParticipationEntry _entry;
|
||
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);
|
||
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;
|
||
_entry = entry;
|
||
_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);
|
||
}
|
||
}
|
||
|
||
public int? GetRating(string key) =>
|
||
_entry.Ratings.FirstOrDefault(r => r.Key == 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));
|
||
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));
|
||
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",
|
||
_ => "Anwesend",
|
||
};
|
||
|
||
public static string ShortLabel(AttendanceStatus? s) => s switch
|
||
{
|
||
null => "",
|
||
AttendanceStatus.Present => "✓",
|
||
AttendanceStatus.ExcusePending => "?",
|
||
AttendanceStatus.Excused => "⊘",
|
||
AttendanceStatus.Unexcused => "!",
|
||
AttendanceStatus.Truant => "✕",
|
||
AttendanceStatus.OtherSchoolEvent => "◇",
|
||
_ => "",
|
||
};
|
||
|
||
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",
|
||
_ => "",
|
||
};
|
||
}
|
||
|
||
// ── 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
|
||
{
|
||
[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; }
|
||
|
||
[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 = "";
|
||
Result = new ParticipationSession { Date = date, Comment = Comment.Trim() };
|
||
}
|
||
}
|
||
|
||
// ── 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 = "";
|
||
|
||
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 · " +
|
||
"Enter nächster Schüler · Backspace vorheriger · +/− 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));
|
||
|
||
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}";
|
||
|
||
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 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();
|
||
}
|
||
}
|