1210 lines
48 KiB
C#
1210 lines
48 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()
|
||
{
|
||
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));
|
||
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 IReadOnlyList<AspectColumnDef> _aspectDefs;
|
||
|
||
public ObservableCollection<RatingCell> Cells { get; } = [];
|
||
public ObservableCollection<RatingCell> CompetencyCells { get; } = [];
|
||
|
||
[ObservableProperty] private HomeworkStatus? _homework;
|
||
[ObservableProperty] private AttendanceStatus? _attendance;
|
||
|
||
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
|
||
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
|
||
/// Abwesend im Sinne der Mitarbeitsbewertung: eine Bewertung ergibt für diese Stunde keinen
|
||
/// Sinn, unabhängig davon, ob die Abwesenheit entschuldigt ist oder noch geklärt werden muss.
|
||
public bool IsAbsent => Attendance is not null
|
||
and not AttendanceStatus.Present
|
||
and not AttendanceStatus.Late
|
||
and not AttendanceStatus.SignificantlyLate;
|
||
public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework);
|
||
public string HomeworkTooltip => HomeworkDisplay.Label(Homework);
|
||
|
||
public Action<Guid, string, int?>? OnRatingChanged { get; set; }
|
||
public Action<Guid, string, int?>? OnCompetencyRatingChanged { get; set; }
|
||
public Action<Guid, HomeworkStatus?>? HomeworkChangedCallback { get; set; }
|
||
public Action<Guid, AttendanceStatus?>? AttendanceChangedCallback { get; set; }
|
||
|
||
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
|
||
List<AspectColumnDef> aspects, List<string> competencyCodes)
|
||
{
|
||
StudentId = id;
|
||
Name = name;
|
||
_aspectDefs = aspects;
|
||
_homework = HomeworkDisplay.Effective(entry);
|
||
_attendance = entry.Attendance;
|
||
|
||
foreach (var a in aspects)
|
||
{
|
||
var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key);
|
||
var cell = new RatingCell(id, a.Key, existing?.Value, a.ValueType, a.MaxPoints);
|
||
cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val);
|
||
Cells.Add(cell);
|
||
}
|
||
|
||
foreach (var code in competencyCodes)
|
||
{
|
||
var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code);
|
||
var cell = new RatingCell(id, code, existing?.Value);
|
||
cell.OnChanged = (sid, key, val) => OnCompetencyRatingChanged?.Invoke(sid, key, val);
|
||
CompetencyCells.Add(cell);
|
||
}
|
||
}
|
||
|
||
// Liest aus Cells (per SetValue laufend aktuell gehalten), NICHT aus dem ursprünglichen
|
||
// ParticipationEntry-Snapshot: SetRating() schreibt nur in Cells + feuert OnRatingChanged
|
||
// (der Callback speichert über eine EIGENE, neu aus dem Repository geladene Entry-Instanz —
|
||
// der hier ursprünglich referenzierte Entry-Snapshot bekommt diese Änderung nie zu sehen).
|
||
public int? GetRating(string key) =>
|
||
Cells.FirstOrDefault(c => c.AspectKey == key)?.Value;
|
||
|
||
public void SetRating(string key, int? value)
|
||
{
|
||
var cell = Cells.FirstOrDefault(c => c.AspectKey == key);
|
||
cell?.SetValue(value);
|
||
OnRatingChanged?.Invoke(StudentId, key, value);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ToggleHomework()
|
||
{
|
||
Homework = HomeworkDisplay.Next(Homework);
|
||
OnPropertyChanged(nameof(HomeworkSymbol));
|
||
OnPropertyChanged(nameof(HomeworkTooltip));
|
||
HomeworkChangedCallback?.Invoke(StudentId, Homework);
|
||
}
|
||
|
||
public void SetHomework(HomeworkStatus? value)
|
||
{
|
||
Homework = value;
|
||
OnPropertyChanged(nameof(HomeworkSymbol));
|
||
OnPropertyChanged(nameof(HomeworkTooltip));
|
||
HomeworkChangedCallback?.Invoke(StudentId, value);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CycleAttendance()
|
||
{
|
||
Attendance = Attendance switch
|
||
{
|
||
null => AttendanceStatus.Present,
|
||
AttendanceStatus.Present => AttendanceStatus.ExcusePending,
|
||
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
|
||
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
|
||
AttendanceStatus.Unexcused => AttendanceStatus.Truant,
|
||
AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent,
|
||
AttendanceStatus.OtherSchoolEvent => null,
|
||
_ => null,
|
||
};
|
||
OnPropertyChanged(nameof(AttendanceLabel));
|
||
OnPropertyChanged(nameof(AttendanceTooltip));
|
||
OnPropertyChanged(nameof(IsAbsent));
|
||
AttendanceChangedCallback?.Invoke(StudentId, Attendance);
|
||
}
|
||
|
||
// Direktes Setzen (z.B. aus dem Grading-Wizard heraus), ohne den Zyklus zu durchlaufen.
|
||
public void SetAttendance(AttendanceStatus? value)
|
||
{
|
||
Attendance = value;
|
||
OnPropertyChanged(nameof(AttendanceLabel));
|
||
OnPropertyChanged(nameof(AttendanceTooltip));
|
||
OnPropertyChanged(nameof(IsAbsent));
|
||
AttendanceChangedCallback?.Invoke(StudentId, value);
|
||
}
|
||
}
|
||
|
||
// ── Hausaufgaben-Anzeige ─────────────────────────────────────────────────────
|
||
|
||
public static class HomeworkDisplay
|
||
{
|
||
public static HomeworkStatus? Effective(ParticipationEntry entry) =>
|
||
entry.Homework ?? (entry.HomeworkMissing ? HomeworkStatus.MissingOpen : null);
|
||
|
||
public static HomeworkStatus? Next(HomeworkStatus? status) => status switch
|
||
{
|
||
null => HomeworkStatus.Completed,
|
||
HomeworkStatus.Completed => HomeworkStatus.PartiallyCompleted,
|
||
HomeworkStatus.PartiallyCompleted => HomeworkStatus.PartialSubmittedLate,
|
||
HomeworkStatus.PartialSubmittedLate => HomeworkStatus.PartialMissingOverdue,
|
||
HomeworkStatus.PartialMissingOverdue => HomeworkStatus.MissingOpen,
|
||
HomeworkStatus.MissingOpen => HomeworkStatus.MissingOverdue,
|
||
HomeworkStatus.MissingOverdue => HomeworkStatus.SubmittedLate,
|
||
HomeworkStatus.SubmittedLate => null,
|
||
_ => null,
|
||
};
|
||
|
||
public static string Label(HomeworkStatus? status) => status switch
|
||
{
|
||
null => "Keine Hausaufgabe aufgegeben",
|
||
HomeworkStatus.Completed => "Hausaufgabe gemacht",
|
||
HomeworkStatus.PartiallyCompleted => "Teilweise angefertigt – Rest offen",
|
||
HomeworkStatus.PartialSubmittedLate => "Teilweise angefertigt – Rest nachgereicht",
|
||
HomeworkStatus.PartialMissingOverdue => "Teilweise angefertigt – Rest nicht nachgereicht",
|
||
HomeworkStatus.MissingOpen => "Nicht gemacht – Nachreichen offen",
|
||
HomeworkStatus.MissingOverdue => "Nicht gemacht – nicht mehr nachgereicht",
|
||
HomeworkStatus.SubmittedLate => "Hausaufgabe nachgereicht",
|
||
_ => "Keine Hausaufgabe aufgegeben",
|
||
};
|
||
|
||
public static string Symbol(HomeworkStatus? status) => status switch
|
||
{
|
||
null => "·",
|
||
HomeworkStatus.Completed => "✓",
|
||
HomeworkStatus.PartiallyCompleted => "◐",
|
||
HomeworkStatus.PartialSubmittedLate => "◕",
|
||
HomeworkStatus.PartialMissingOverdue => "◒",
|
||
HomeworkStatus.MissingOpen => "!",
|
||
HomeworkStatus.MissingOverdue => "✕",
|
||
HomeworkStatus.SubmittedLate => "↺",
|
||
_ => "·",
|
||
};
|
||
|
||
public static string Color(HomeworkStatus? status) => status switch
|
||
{
|
||
HomeworkStatus.Completed => "#2E9D57",
|
||
HomeworkStatus.PartiallyCompleted => "#D98200",
|
||
HomeworkStatus.PartialSubmittedLate => "#7F77DD",
|
||
HomeworkStatus.PartialMissingOverdue => "#D64545",
|
||
HomeworkStatus.MissingOpen => "#D98200",
|
||
HomeworkStatus.MissingOverdue => "#D64545",
|
||
HomeworkStatus.SubmittedLate => "#7F77DD",
|
||
_ => "",
|
||
};
|
||
|
||
public static bool CountsAsMissing(HomeworkStatus? status) => status is
|
||
HomeworkStatus.MissingOpen or
|
||
HomeworkStatus.MissingOverdue or
|
||
HomeworkStatus.PartiallyCompleted or
|
||
HomeworkStatus.PartialMissingOverdue;
|
||
}
|
||
|
||
// ── Anwesenheits-Anzeige ──────────────────────────────────────────────────────
|
||
|
||
public static class AttendanceDisplay
|
||
{
|
||
public static string Label(AttendanceStatus? s) => s switch
|
||
{
|
||
null => "Noch nicht kontrolliert",
|
||
AttendanceStatus.Present => "Anwesend",
|
||
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
|
||
AttendanceStatus.Excused => "Krank, entschuldigt",
|
||
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
|
||
AttendanceStatus.Truant => "Geschwänzt",
|
||
AttendanceStatus.OtherSchoolEvent => "Andere Schulveranstaltung",
|
||
AttendanceStatus.Late => "Verspätet",
|
||
AttendanceStatus.SignificantlyLate => "Erheblich verspätet",
|
||
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
|
||
AttendanceStatus.LearningIsland => "Lerninsel",
|
||
AttendanceStatus.Suspended => "Suspendiert",
|
||
_ => "Anwesend",
|
||
};
|
||
|
||
public static string ShortLabel(AttendanceStatus? s) => s switch
|
||
{
|
||
null => "",
|
||
AttendanceStatus.Present => "✓",
|
||
AttendanceStatus.ExcusePending => "?",
|
||
AttendanceStatus.Excused => "⊘",
|
||
AttendanceStatus.Unexcused => "!",
|
||
AttendanceStatus.Truant => "✕",
|
||
AttendanceStatus.OtherSchoolEvent => "◇",
|
||
AttendanceStatus.Late => "V",
|
||
AttendanceStatus.SignificantlyLate => "V!",
|
||
AttendanceStatus.LeftDuringClass => "A",
|
||
AttendanceStatus.LearningIsland => "L",
|
||
AttendanceStatus.Suspended => "S",
|
||
_ => "",
|
||
};
|
||
|
||
public static string Color(AttendanceStatus? s) => s switch
|
||
{
|
||
AttendanceStatus.Present => "#2E9D57",
|
||
AttendanceStatus.ExcusePending => "#D98200",
|
||
AttendanceStatus.Excused => "#4C86A8",
|
||
AttendanceStatus.Unexcused => "#D96C00",
|
||
AttendanceStatus.Truant => "#D64545",
|
||
AttendanceStatus.OtherSchoolEvent => "#5277C3",
|
||
AttendanceStatus.Late => "#D98200",
|
||
AttendanceStatus.SignificantlyLate => "#D96C00",
|
||
AttendanceStatus.LeftDuringClass => "#D64545",
|
||
AttendanceStatus.LearningIsland => "#5277C3",
|
||
AttendanceStatus.Suspended => "#6B6576",
|
||
_ => "",
|
||
};
|
||
}
|
||
|
||
// ── Eine Bewertungszelle ──────────────────────────────────────────────────────
|
||
|
||
public partial class RatingCell : ObservableObject
|
||
{
|
||
public Guid StudentId { get; }
|
||
public string AspectKey { get; }
|
||
public AspectValueType Type { get; }
|
||
public int MaxPoints { get; }
|
||
|
||
[ObservableProperty] private int? _value;
|
||
[ObservableProperty] private string _displayLabel = "";
|
||
|
||
public Action<Guid, string, int?>? OnChanged { get; set; }
|
||
|
||
public RatingCell(Guid studentId, string key, int? value,
|
||
AspectValueType type = AspectValueType.Scale5, int maxPoints = 5)
|
||
{
|
||
StudentId = studentId;
|
||
AspectKey = key;
|
||
Type = type;
|
||
MaxPoints = maxPoints;
|
||
_value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value;
|
||
UpdateLabel();
|
||
}
|
||
|
||
public void SetValue(int? value)
|
||
{
|
||
Value = Type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, MaxPoints) : value;
|
||
UpdateLabel();
|
||
OnChanged?.Invoke(StudentId, AspectKey, Value);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CycleUp()
|
||
{
|
||
if (Type == AspectValueType.Points)
|
||
{
|
||
SetValue(Value is null ? 0 : Math.Min(MaxPoints, Value.Value + 1));
|
||
return;
|
||
}
|
||
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
|
||
if (steps.Count == 0) return;
|
||
var idx = Value is null ? -1 : steps.IndexOf(Value.Value);
|
||
SetValue(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CycleDown()
|
||
{
|
||
if (Type == AspectValueType.Points)
|
||
{
|
||
SetValue(Value is null ? MaxPoints : Math.Max(0, Value.Value - 1));
|
||
return;
|
||
}
|
||
var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList();
|
||
if (steps.Count == 0) return;
|
||
var idx = Value is null ? steps.Count : steps.IndexOf(Value.Value);
|
||
SetValue(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Clear() => SetValue(null);
|
||
|
||
private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
|
||
}
|
||
|
||
// ── Kompetenz-Tags ────────────────────────────────────────────────────────────
|
||
|
||
public class CompetencyTagGroup(string name, string code)
|
||
{
|
||
public string Name { get; } = name;
|
||
public string Code { get; } = code;
|
||
public string DisplayName { get; } = string.IsNullOrEmpty(code) ? name : $"{name} ({code})";
|
||
public List<CompetencyTag> Items { get; } = [];
|
||
}
|
||
|
||
public partial class CompetencyTag : ObservableObject
|
||
{
|
||
public string Code { get; }
|
||
public string Description { get; }
|
||
public string Display { get; }
|
||
|
||
[ObservableProperty] private bool _isSelected;
|
||
|
||
public Action<string, bool>? OnChanged { get; set; }
|
||
|
||
public CompetencyTag(string code, string description, bool isSelected)
|
||
{
|
||
Code = code;
|
||
Description = description;
|
||
Display = string.IsNullOrEmpty(code) ? description : $"[{code}] {description}";
|
||
_isSelected = isSelected;
|
||
}
|
||
|
||
partial void OnIsSelectedChanged(bool value) => OnChanged?.Invoke(Code, value);
|
||
}
|
||
|
||
// ── Hilfsklassen ──────────────────────────────────────────────────────────────
|
||
|
||
public class AspectColumnDef
|
||
{
|
||
public string Key { get; }
|
||
public string Label { get; }
|
||
public AspectValueType ValueType { get; }
|
||
public int MaxPoints { get; }
|
||
public AspectColumnDef(ParticipationAspect a)
|
||
{
|
||
Key = a.Key; Label = a.Label; ValueType = a.ValueType; MaxPoints = a.MaxPoints;
|
||
}
|
||
}
|
||
|
||
public class ParticipationSessionItem
|
||
{
|
||
public Guid Id { get; }
|
||
public string Display { get; }
|
||
public string Comment { get; }
|
||
public DateOnly Date { get; }
|
||
|
||
public ParticipationSessionItem(ParticipationSession s)
|
||
{
|
||
Id = s.Id;
|
||
Date = s.Date;
|
||
Comment = s.Comment ?? "";
|
||
Display = s.Comment is { Length: > 0 }
|
||
? $"{s.Date:dd.MM.yyyy} – {s.Comment}"
|
||
: s.Date.ToString("dd.MM.yyyy");
|
||
}
|
||
}
|
||
|
||
// ── Dialog: Sitzung anlegen ───────────────────────────────────────────────────
|
||
|
||
public partial class AddSessionDialogViewModel : ObservableObject
|
||
{
|
||
[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 = "";
|
||
[ObservableProperty] private bool _currentStudentIsAbsent;
|
||
[ObservableProperty] private string _currentStudentAttendanceLabel = "";
|
||
|
||
/// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der
|
||
/// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein
|
||
/// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt.
|
||
public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0;
|
||
|
||
public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
|
||
|
||
/// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere
|
||
/// gültige Zifferntasten als Scale5, Points nutzt 0-9 als direkte Punkteingabe statt fester
|
||
/// Stufen. Ändert sich mit AspectIndex, siehe OnAspectIndexChanged.
|
||
public string HotkeyLegend
|
||
{
|
||
get
|
||
{
|
||
var ratingHint = CurrentAspectType() switch
|
||
{
|
||
AspectValueType.Scale5 => "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 · Esc schließen";
|
||
}
|
||
}
|
||
|
||
public QuickInputViewModel(List<ParticipationStudentRow> rows, List<AspectColumnDef> aspects)
|
||
{
|
||
_rows = rows;
|
||
_aspects = aspects;
|
||
if (rows.Any()) ShowStudent(0);
|
||
}
|
||
|
||
partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend));
|
||
partial void OnCurrentStudentIsAbsentChanged(bool value) => OnPropertyChanged(nameof(CurrentStudentContentOpacity));
|
||
|
||
private AspectValueType CurrentAspectType() =>
|
||
_aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType;
|
||
private int CurrentAspectMaxPoints() =>
|
||
_aspects.Count == 0 ? 5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].MaxPoints;
|
||
|
||
private void ShowStudent(int index)
|
||
{
|
||
if (index < 0 || index >= _rows.Count) return;
|
||
StudentIndex = index;
|
||
var row = _rows[index];
|
||
StudentName = row.Name;
|
||
ProgressText = $"{index + 1} / {_rows.Count}";
|
||
CurrentStudentIsAbsent = row.IsAbsent;
|
||
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
|
||
|
||
AspectRows.Clear();
|
||
foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
|
||
{
|
||
var val = row.GetRating(a.Key);
|
||
AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex, a.ValueType));
|
||
}
|
||
UpdateCurrentAspect();
|
||
}
|
||
|
||
private void UpdateCurrentAspect()
|
||
{
|
||
if (!_aspects.Any()) return;
|
||
var safeIdx = Math.Clamp(AspectIndex, 0, _aspects.Count - 1);
|
||
CurrentAspectLabel = _aspects[safeIdx].Label;
|
||
var row = AspectRows.ElementAtOrDefault(safeIdx);
|
||
CurrentValueLabel = row?.DisplayLabel ?? "";
|
||
foreach (var r in AspectRows) r.IsActive = r.Index == safeIdx;
|
||
}
|
||
|
||
public void SetRatingByNumber(int num)
|
||
{
|
||
var type = CurrentAspectType();
|
||
if (type == AspectValueType.Points)
|
||
{
|
||
ApplyRating(Math.Clamp(num, 0, CurrentAspectMaxPoints()));
|
||
return;
|
||
}
|
||
var steps = ParticipationRatingScale.Steps(type);
|
||
if (num < 1 || num > steps.Count) return;
|
||
ApplyRating(steps[num - 1].Value);
|
||
}
|
||
|
||
public void IncrementRating()
|
||
{
|
||
var cell = GetCurrentCell();
|
||
if (cell is null) return;
|
||
var type = CurrentAspectType();
|
||
if (type == AspectValueType.Points)
|
||
{
|
||
var max = CurrentAspectMaxPoints();
|
||
ApplyRating(cell.Value is null ? 0 : Math.Min(max, cell.Value.Value + 1));
|
||
return;
|
||
}
|
||
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
|
||
if (steps.Count == 0) return;
|
||
var idx = cell.Value is null ? -1 : steps.IndexOf(cell.Value.Value);
|
||
ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]);
|
||
}
|
||
|
||
public void DecrementRating()
|
||
{
|
||
var cell = GetCurrentCell();
|
||
if (cell is null) return;
|
||
var type = CurrentAspectType();
|
||
if (type == AspectValueType.Points)
|
||
{
|
||
ApplyRating(cell.Value is null ? CurrentAspectMaxPoints() : Math.Max(0, cell.Value.Value - 1));
|
||
return;
|
||
}
|
||
var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList();
|
||
if (steps.Count == 0) return;
|
||
var idx = cell.Value is null ? steps.Count : steps.IndexOf(cell.Value.Value);
|
||
ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]);
|
||
}
|
||
|
||
private void ApplyRating(int val)
|
||
{
|
||
if (_rows.Count == 0 || !_aspects.Any()) return;
|
||
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
|
||
_rows[StudentIndex].SetRating(key, val);
|
||
var row = AspectRows.ElementAtOrDefault(AspectIndex);
|
||
if (row is not null)
|
||
{
|
||
row.Value = val;
|
||
row.UpdateLabel();
|
||
}
|
||
CurrentValueLabel = ParticipationRatingScale.DisplayLabel(CurrentAspectType(), val);
|
||
}
|
||
|
||
public void SelectAspect(int index)
|
||
{
|
||
if (index < 0 || index >= _aspects.Count) return;
|
||
AspectIndex = index;
|
||
UpdateCurrentAspect();
|
||
}
|
||
|
||
public void NextAspect()
|
||
{
|
||
AspectIndex = (AspectIndex + 1) % _aspects.Count;
|
||
UpdateCurrentAspect();
|
||
}
|
||
|
||
public void PreviousAspect()
|
||
{
|
||
AspectIndex = (AspectIndex - 1 + _aspects.Count) % _aspects.Count;
|
||
UpdateCurrentAspect();
|
||
}
|
||
|
||
public void NextStudent()
|
||
{
|
||
if (StudentIndex >= _rows.Count - 1) return;
|
||
AspectIndex = 0;
|
||
ShowStudent(StudentIndex + 1);
|
||
}
|
||
|
||
public void PreviousStudent()
|
||
{
|
||
if (StudentIndex <= 0) return;
|
||
AspectIndex = 0;
|
||
ShowStudent(StudentIndex - 1);
|
||
}
|
||
|
||
public bool IsLastStudent => StudentIndex >= _rows.Count - 1;
|
||
|
||
private RatingCell? GetCurrentCell()
|
||
{
|
||
if (_rows.Count == 0 || !_aspects.Any()) return null;
|
||
var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key;
|
||
return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key);
|
||
}
|
||
}
|
||
|
||
public partial class QuickAspectRow : ObservableObject
|
||
{
|
||
public int Index { get; }
|
||
public string Label { get; }
|
||
public AspectValueType Type { get; }
|
||
[ObservableProperty] private bool _isActive;
|
||
[ObservableProperty] private string _displayLabel = "";
|
||
public int? Value { get; set; }
|
||
|
||
public QuickAspectRow(int index, string label, int? value, bool isActive, AspectValueType type = AspectValueType.Scale5)
|
||
{
|
||
Index = index;
|
||
Label = label;
|
||
Value = value;
|
||
IsActive = isActive;
|
||
Type = type;
|
||
UpdateLabel();
|
||
}
|
||
|
||
public void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value);
|
||
}
|
||
|
||
// ── Schnelleingabe Anwesenheit / Hausaufgaben ────────────────────────────────
|
||
|
||
public partial class AttendanceHomeworkQuickInputViewModel : ObservableObject
|
||
{
|
||
public List<ParticipationStudentRow> Rows { get; }
|
||
public string SessionLabel { get; }
|
||
|
||
[ObservableProperty] private ParticipationStudentRow? _selectedRow;
|
||
[ObservableProperty] private bool _advanceAfterInput = true;
|
||
|
||
public AttendanceHomeworkQuickInputViewModel(
|
||
IEnumerable<ParticipationStudentRow> rows, string sessionLabel)
|
||
{
|
||
Rows = rows.ToList();
|
||
SessionLabel = sessionLabel;
|
||
SelectedRow = Rows.FirstOrDefault();
|
||
}
|
||
|
||
public void ApplyAttendance(ParticipationStudentRow row, AttendanceStatus? status)
|
||
{
|
||
SelectedRow = row;
|
||
row.SetAttendance(status);
|
||
AdvanceIfRequested();
|
||
}
|
||
|
||
public void ApplyHomework(ParticipationStudentRow row, HomeworkStatus? status)
|
||
{
|
||
SelectedRow = row;
|
||
row.SetHomework(status);
|
||
AdvanceIfRequested();
|
||
}
|
||
|
||
public void MoveSelection(int delta)
|
||
{
|
||
if (Rows.Count == 0) return;
|
||
var current = SelectedRow is null ? 0 : Rows.IndexOf(SelectedRow);
|
||
SelectedRow = Rows[Math.Clamp(current + delta, 0, Rows.Count - 1)];
|
||
}
|
||
|
||
private void AdvanceIfRequested()
|
||
{
|
||
if (AdvanceAfterInput) MoveSelection(1);
|
||
}
|
||
}
|
||
|
||
// ── Aspekte verwalten (3.1) ──────────────────────────────────────────────────
|
||
|
||
/// Deutsche Anzeige für <see cref="AspectValueType"/>, gleiches Muster wie NiveauDisplay/
|
||
/// GradeCategoryDisplay — ComboBox bindet an das Enum sonst über ToString() (englische Namen).
|
||
public static class AspectValueTypeDisplay
|
||
{
|
||
public static string[] Options { get; } = ["Skala 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();
|
||
}
|
||
}
|