837 lines
34 KiB
C#
837 lines
34 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;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||
|
||
public partial class SeatingPlanTabViewModel : ObservableObject
|
||
{
|
||
private readonly ISeatingPlanRepository _plans;
|
||
private readonly IStudentRepository _students;
|
||
private readonly IGroupMembershipRepository _memberships;
|
||
private readonly IParticipationSessionRepository _sessions;
|
||
private readonly IParticipationRepository _participation;
|
||
private readonly IParticipationAspectRepository _aspects;
|
||
private readonly IDocumentationRepository? _documentation;
|
||
private Guid _groupId;
|
||
private SeatingPlan? _currentPlan;
|
||
private bool _isReadOnly;
|
||
|
||
[ObservableProperty] private SeatingPlanSummary? _selectedPlan;
|
||
[ObservableProperty] private int _planColumns = 1;
|
||
[ObservableProperty] private string _planTitle = "";
|
||
[ObservableProperty] private string _planSubtitle = "";
|
||
[ObservableProperty] private string _assignmentSummary = "";
|
||
[ObservableProperty] private bool _isBoardAtTop = true;
|
||
[ObservableProperty] private bool _isBoardAtBottom;
|
||
[ObservableProperty] private IReadOnlyList<double> _columnGapWidths = [];
|
||
[ObservableProperty] private bool _isEditMode;
|
||
[ObservableProperty] private ParticipationSessionOption? _selectedSession;
|
||
|
||
public ObservableCollection<SeatingPlanSummary> Plans { get; } = [];
|
||
public ObservableCollection<SeatCellViewModel> Seats { get; } = [];
|
||
public ObservableCollection<StudentSeatOption> StudentOptions { get; } = [];
|
||
public ObservableCollection<StudentSeatOption> UnassignedStudents { get; } = [];
|
||
public ObservableCollection<ParticipationSessionOption> TodaySessions { get; } = [];
|
||
|
||
public bool HasPlans => Plans.Count > 0;
|
||
public bool HasSelectedPlan => _currentPlan is not null;
|
||
public bool IsEditable => !_isReadOnly;
|
||
public bool CanEditLayout => IsEditable && IsEditMode;
|
||
public Func<SeatingPlan?, Task<SeatingPlan?>>? OnEditPlan { get; set; }
|
||
public Func<SeatingPlanSummary, Task<bool>>? OnConfirmDelete { get; set; }
|
||
public Func<SeatAssessmentViewModel, Task>? OnAssessStudent { get; set; }
|
||
public Action? OnAssessmentChanged { get; set; }
|
||
public Action? OnDocumentationChanged { get; set; }
|
||
|
||
public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students,
|
||
IGroupMembershipRepository memberships, IParticipationSessionRepository sessions,
|
||
IParticipationRepository participation, IParticipationAspectRepository aspects,
|
||
IDocumentationRepository? documentation = null)
|
||
{
|
||
_plans = plans;
|
||
_students = students;
|
||
_memberships = memberships;
|
||
_sessions = sessions;
|
||
_participation = participation;
|
||
_aspects = aspects;
|
||
_documentation = documentation;
|
||
}
|
||
|
||
public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) =>
|
||
new(_plans, _groupId, plan);
|
||
|
||
public void Initialize(Guid groupId, bool isReadOnly)
|
||
{
|
||
_groupId = groupId;
|
||
_isReadOnly = isReadOnly;
|
||
IsEditMode = false;
|
||
LoadTodaySessions();
|
||
LoadStudentOptions();
|
||
ReloadPlans();
|
||
OnPropertyChanged(nameof(IsEditable));
|
||
NotifyCommands();
|
||
}
|
||
|
||
private void LoadTodaySessions()
|
||
{
|
||
TodaySessions.Clear();
|
||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||
var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today)
|
||
.OrderBy(s => s.CreatedAt).ToList();
|
||
if (sessions.Count == 0 && !_isReadOnly)
|
||
{
|
||
var created = new ParticipationSession { GroupId = _groupId, Date = today, Comment = "Sitzplan" };
|
||
_sessions.Save(created);
|
||
sessions.Add(created);
|
||
}
|
||
foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session));
|
||
SelectedSession = TodaySessions.LastOrDefault();
|
||
}
|
||
|
||
partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData();
|
||
|
||
private void LoadStudentOptions()
|
||
{
|
||
StudentOptions.Clear();
|
||
StudentOptions.Add(StudentSeatOption.Empty);
|
||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||
var memberships = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
|
||
foreach (var student in _students.GetByGroup(_groupId)
|
||
.Where(s => memberships.TryGetValue(s.Id, out var membership)
|
||
&& GroupMembershipService.IsActiveOn(membership, today))
|
||
.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||
StudentOptions.Add(new StudentSeatOption(student.Id, $"{student.LastName}, {student.FirstName}"));
|
||
}
|
||
|
||
private void ReloadPlans(Guid? selectId = null)
|
||
{
|
||
selectId ??= SelectedPlan?.Id;
|
||
Plans.Clear();
|
||
foreach (var plan in _plans.GetByGroup(_groupId))
|
||
Plans.Add(new SeatingPlanSummary(plan));
|
||
SelectedPlan = Plans.FirstOrDefault(p => p.Id == selectId) ?? Plans.FirstOrDefault();
|
||
if (SelectedPlan is null) LoadPlan(null);
|
||
OnPropertyChanged(nameof(HasPlans));
|
||
NotifyCommands();
|
||
}
|
||
|
||
partial void OnSelectedPlanChanged(SeatingPlanSummary? value) =>
|
||
LoadPlan(value is null ? null : _plans.GetById(value.Id));
|
||
|
||
private void LoadPlan(SeatingPlan? plan)
|
||
{
|
||
_currentPlan = plan;
|
||
Seats.Clear();
|
||
if (plan is null)
|
||
{
|
||
UnassignedStudents.Clear();
|
||
PlanColumns = 1;
|
||
PlanTitle = "";
|
||
PlanSubtitle = "";
|
||
AssignmentSummary = "";
|
||
IsBoardAtTop = true;
|
||
IsBoardAtBottom = false;
|
||
ColumnGapWidths = [];
|
||
}
|
||
else
|
||
{
|
||
PlanColumns = plan.Columns;
|
||
PlanTitle = plan.Name;
|
||
PlanSubtitle = string.IsNullOrWhiteSpace(plan.Room)
|
||
? $"{plan.Rows} × {plan.Columns} Plätze"
|
||
: $"Raum {plan.Room} · {plan.Rows} × {plan.Columns} Plätze";
|
||
IsBoardAtBottom = plan.IsBoardAtBottom;
|
||
IsBoardAtTop = !plan.IsBoardAtBottom;
|
||
var savedGapWidths = plan.ColumnGapWidths ?? [];
|
||
ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1))
|
||
.Select(i => i < savedGapWidths.Count ? savedGapWidths[i] : 0)
|
||
.ToArray();
|
||
for (var row = 0; row < plan.Rows; row++)
|
||
for (var column = 0; column < plan.Columns; column++)
|
||
{
|
||
var assignment = plan.Assignments.FirstOrDefault(a => a.Row == row && a.Column == column);
|
||
var option = assignment is null
|
||
? StudentSeatOption.Empty
|
||
: StudentOptions.FirstOrDefault(o => o.StudentId == assignment.StudentId)
|
||
?? StudentSeatOption.Empty;
|
||
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
||
CanEditLayout, ToggleSituationTag, IsEditable));
|
||
}
|
||
UpdateAssignmentSummary();
|
||
RefreshSeatLessonData();
|
||
}
|
||
OnPropertyChanged(nameof(HasSelectedPlan));
|
||
NotifyCommands();
|
||
}
|
||
|
||
private void RefreshSeatLessonData()
|
||
{
|
||
if (SelectedSession is null)
|
||
{
|
||
foreach (var seat in Seats) seat.SetLessonData(null, []);
|
||
return;
|
||
}
|
||
var entries = _participation.GetBySession(SelectedSession.Id)
|
||
.ToDictionary(e => e.StudentId);
|
||
foreach (var seat in Seats)
|
||
{
|
||
if (seat.SelectedOption.StudentId is not Guid studentId)
|
||
{
|
||
seat.SetLessonData(null, []);
|
||
continue;
|
||
}
|
||
entries.TryGetValue(studentId, out var entry);
|
||
var tags = _documentation?.GetByStudent(studentId)
|
||
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id)?.Tags ?? [];
|
||
seat.SetLessonData(entry, tags);
|
||
}
|
||
}
|
||
|
||
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
|
||
{
|
||
if (!IsEditable || _documentation is null || SelectedSession is null ||
|
||
seat.SelectedOption.StudentId is not Guid studentId) return;
|
||
var draft = _documentation.GetByStudent(studentId)
|
||
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id);
|
||
if (draft is null)
|
||
{
|
||
draft = new Documentation
|
||
{
|
||
StudentId = studentId, GroupId = _groupId,
|
||
ParticipationSessionId = SelectedSession.Id,
|
||
LessonId = SelectedSession.LessonId,
|
||
Type = DocumentationType.Incident,
|
||
Date = SelectedSession.Date,
|
||
Title = tag,
|
||
IsDraft = true,
|
||
Tags = [tag],
|
||
};
|
||
}
|
||
else if (draft.Tags.Contains(tag))
|
||
{
|
||
draft.Tags.Remove(tag);
|
||
if (draft.Tags.Count == 0)
|
||
{
|
||
_documentation.Delete(draft.Id);
|
||
RefreshSeatLessonData();
|
||
OnDocumentationChanged?.Invoke();
|
||
return;
|
||
}
|
||
draft.Title = draft.Tags[0];
|
||
}
|
||
else draft.Tags.Add(tag);
|
||
draft.UpdatedAt = DateTime.UtcNow;
|
||
_documentation.Save(draft);
|
||
RefreshSeatLessonData();
|
||
OnDocumentationChanged?.Invoke();
|
||
}
|
||
|
||
private void OnSeatChanged(SeatCellViewModel changed)
|
||
{
|
||
if (_currentPlan is null || !CanEditLayout) return;
|
||
if (changed.SelectedOption.StudentId is Guid studentId)
|
||
{
|
||
foreach (var other in Seats.Where(s => s != changed && s.SelectedOption.StudentId == studentId))
|
||
other.SetSelectionSilently(StudentSeatOption.Empty);
|
||
}
|
||
|
||
SaveSeatAssignments();
|
||
}
|
||
|
||
private void UpdateAssignmentSummary()
|
||
{
|
||
var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue);
|
||
var total = StudentOptions.Count - 1;
|
||
AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet";
|
||
var assignedIds = Seats.Where(s => s.SelectedOption.StudentId.HasValue)
|
||
.Select(s => s.SelectedOption.StudentId!.Value).ToHashSet();
|
||
UnassignedStudents.Clear();
|
||
foreach (var option in StudentOptions.Where(o => o.StudentId.HasValue && !assignedIds.Contains(o.StudentId.Value)))
|
||
UnassignedStudents.Add(option);
|
||
}
|
||
|
||
public void MoveSeat(SeatCellViewModel source, SeatCellViewModel target)
|
||
{
|
||
if (!CanEditLayout || source == target || !source.SelectedOption.StudentId.HasValue) return;
|
||
var targetOption = target.SelectedOption;
|
||
target.SetSelectionSilently(source.SelectedOption);
|
||
source.SetSelectionSilently(targetOption);
|
||
SaveSeatAssignments();
|
||
}
|
||
|
||
public void AssignStudent(StudentSeatOption student, SeatCellViewModel target)
|
||
{
|
||
if (!CanEditLayout || !student.StudentId.HasValue) return;
|
||
foreach (var other in Seats.Where(s => s != target && s.SelectedOption.StudentId == student.StudentId))
|
||
other.SetSelectionSilently(StudentSeatOption.Empty);
|
||
target.SetSelectionSilently(student);
|
||
SaveSeatAssignments();
|
||
}
|
||
|
||
public void ClearSeat(SeatCellViewModel seat)
|
||
{
|
||
if (!CanEditLayout || !seat.SelectedOption.StudentId.HasValue) return;
|
||
seat.SetSelectionSilently(StudentSeatOption.Empty);
|
||
SaveSeatAssignments();
|
||
}
|
||
|
||
private void SaveSeatAssignments()
|
||
{
|
||
if (_currentPlan is null) return;
|
||
_currentPlan.Assignments = Seats
|
||
.Where(s => s.SelectedOption.StudentId.HasValue)
|
||
.Select(s => new SeatAssignment
|
||
{
|
||
Row = s.Row,
|
||
Column = s.Column,
|
||
StudentId = s.SelectedOption.StudentId!.Value,
|
||
}).ToList();
|
||
_plans.Save(_currentPlan);
|
||
UpdateAssignmentSummary();
|
||
}
|
||
|
||
public async Task AssessStudent(SeatCellViewModel seat)
|
||
{
|
||
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
|
||
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
|
||
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
|
||
SelectedSession?.Id);
|
||
await OnAssessStudent(assessment);
|
||
RefreshSeatLessonData();
|
||
OnAssessmentChanged?.Invoke();
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanEdit))]
|
||
private async Task AddPlan()
|
||
{
|
||
if (OnEditPlan is null) return;
|
||
var plan = await OnEditPlan(null);
|
||
if (plan is not null) ReloadPlans(plan.Id);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanEditSelected))]
|
||
private async Task EditPlan()
|
||
{
|
||
if (_currentPlan is null || OnEditPlan is null) return;
|
||
var plan = await OnEditPlan(_currentPlan);
|
||
if (plan is not null) ReloadPlans(plan.Id);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanEditSelected))]
|
||
private async Task DeletePlan()
|
||
{
|
||
if (SelectedPlan is null || OnConfirmDelete is null || !await OnConfirmDelete(SelectedPlan)) return;
|
||
_plans.Delete(SelectedPlan.Id);
|
||
ReloadPlans();
|
||
}
|
||
|
||
partial void OnIsEditModeChanged(bool value)
|
||
{
|
||
OnPropertyChanged(nameof(CanEditLayout));
|
||
foreach (var seat in Seats)
|
||
{
|
||
seat.CanEdit = CanEditLayout;
|
||
seat.CanRecordLesson = IsEditable && !value;
|
||
}
|
||
NotifyCommands();
|
||
}
|
||
|
||
private bool CanEdit() => IsEditable && (IsEditMode || !HasPlans);
|
||
private bool CanEditSelected() => CanEditLayout && _currentPlan is not null;
|
||
|
||
private void NotifyCommands()
|
||
{
|
||
AddPlanCommand.NotifyCanExecuteChanged();
|
||
EditPlanCommand.NotifyCanExecuteChanged();
|
||
DeletePlanCommand.NotifyCanExecuteChanged();
|
||
}
|
||
}
|
||
|
||
public sealed class SeatingPlanSummary
|
||
{
|
||
public Guid Id { get; }
|
||
public string Name { get; }
|
||
public string RoomDisplay { get; }
|
||
|
||
public SeatingPlanSummary(SeatingPlan plan)
|
||
{
|
||
Id = plan.Id;
|
||
Name = plan.Name;
|
||
RoomDisplay = string.IsNullOrWhiteSpace(plan.Room) ? "Kein Raum" : $"Raum {plan.Room}";
|
||
}
|
||
}
|
||
|
||
public sealed record StudentSeatOption(Guid? StudentId, string DisplayName)
|
||
{
|
||
public static StudentSeatOption Empty { get; } = new(null, "— frei —");
|
||
}
|
||
|
||
public sealed class ParticipationSessionOption(ParticipationSession session)
|
||
{
|
||
public Guid Id => session.Id;
|
||
public Guid? LessonId => session.LessonId;
|
||
public DateOnly Date => session.Date;
|
||
public string DisplayName => string.IsNullOrWhiteSpace(session.Comment)
|
||
? $"{session.Date:dd.MM.yyyy}"
|
||
: $"{session.Date:dd.MM.yyyy} · {session.Comment}";
|
||
}
|
||
|
||
public partial class SeatCellViewModel : ObservableObject
|
||
{
|
||
private readonly Action<SeatCellViewModel> _onChanged;
|
||
private bool _suppressChange;
|
||
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
|
||
|
||
[ObservableProperty] private StudentSeatOption _selectedOption;
|
||
[ObservableProperty] private bool _isDropTarget;
|
||
public int Row { get; }
|
||
public int Column { get; }
|
||
public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}";
|
||
public ObservableCollection<StudentSeatOption> Options { get; }
|
||
[ObservableProperty] private bool _canEdit;
|
||
[ObservableProperty] private double _lessonOpacity = 1.0;
|
||
[ObservableProperty] private string _attendanceBadge = "";
|
||
[ObservableProperty] private string _homeworkBadge = "";
|
||
public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
|
||
public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
|
||
public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
|
||
public bool ShowLessonOverview => IsOccupied && !CanEdit;
|
||
[ObservableProperty] private bool _canRecordLesson;
|
||
public bool IsOccupied => SelectedOption.StudentId.HasValue;
|
||
public string StudentName => IsOccupied ? SelectedOption.DisplayName : "Freier Platz";
|
||
|
||
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
|
||
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
|
||
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false)
|
||
{
|
||
Row = row;
|
||
Column = column;
|
||
Options = options;
|
||
_selectedOption = selectedOption;
|
||
_onChanged = onChanged;
|
||
_canEdit = canEdit;
|
||
_toggleSituationTag = toggleSituationTag ?? ((_, _) => { });
|
||
_canRecordLesson = canRecordLesson;
|
||
foreach (var tag in SituationTagChoice.DefaultTags)
|
||
SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value)));
|
||
}
|
||
|
||
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
||
{
|
||
OnPropertyChanged(nameof(IsOccupied));
|
||
OnPropertyChanged(nameof(StudentName));
|
||
OnPropertyChanged(nameof(ShowLessonOverview));
|
||
if (!_suppressChange) _onChanged(this);
|
||
}
|
||
|
||
partial void OnCanEditChanged(bool value) => OnPropertyChanged(nameof(ShowLessonOverview));
|
||
|
||
public void SetSelectionSilently(StudentSeatOption option)
|
||
{
|
||
_suppressChange = true;
|
||
SelectedOption = option;
|
||
_suppressChange = false;
|
||
}
|
||
|
||
public void SetLessonData(ParticipationEntry? entry, IEnumerable<string> tags)
|
||
{
|
||
var attendance = entry?.Attendance;
|
||
AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance);
|
||
HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry));
|
||
LessonOpacity = attendance is not null and not AttendanceStatus.Present
|
||
and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0;
|
||
var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text);
|
||
OnPropertyChanged(nameof(HasAttendanceBadge));
|
||
OnPropertyChanged(nameof(HasHomeworkBadge));
|
||
}
|
||
}
|
||
|
||
public partial class SituationTagChoice(string text, bool isSelected, Action<string> toggle) : ObservableObject
|
||
{
|
||
public static readonly string[] DefaultTags =
|
||
[
|
||
"Mitarbeit verweigert", "Unterricht gestört", "Streit/Konflikt",
|
||
"Langer Toilettengang", "Material vergessen", "Handynutzung",
|
||
"Besonders hilfsbereit", "Sehr gute Mitarbeit", "Gespräch erforderlich",
|
||
];
|
||
public string Text { get; } = text;
|
||
[ObservableProperty] private bool _isSelected = isSelected;
|
||
[RelayCommand] private void Toggle() => toggle(Text);
|
||
}
|
||
|
||
public partial class SeatAssessmentViewModel : ObservableObject
|
||
{
|
||
private readonly IParticipationRepository _entries;
|
||
private readonly ParticipationEntry? _entry;
|
||
private readonly bool _canEdit;
|
||
|
||
[ObservableProperty] private int _selectedAspectIndex;
|
||
[ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert";
|
||
[ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben";
|
||
|
||
public string StudentName { get; }
|
||
public string SessionDisplay { get; }
|
||
public bool CanEdit => _canEdit && _entry is not null;
|
||
public string ReadOnlyHint => _entry is null
|
||
? "Für heute existiert keine Sitzung. In einer archivierten Gruppe kann keine neue angelegt werden."
|
||
: "Archivierte Lerngruppe – Bewertung nur ansehen.";
|
||
public ObservableCollection<SeatAssessmentAspectRow> AspectRows { get; } = [];
|
||
public ObservableCollection<SeatAttendanceChoice> AttendanceChoices { get; } = [];
|
||
public ObservableCollection<SeatHomeworkChoice> HomeworkChoices { get; } = [];
|
||
|
||
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
|
||
IParticipationRepository entries, IParticipationAspectRepository aspects,
|
||
Guid groupId, Guid studentId, string studentName, bool canEdit, Guid? sessionId = null)
|
||
{
|
||
_entries = entries;
|
||
_canEdit = canEdit;
|
||
StudentName = studentName;
|
||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||
var session = sessionId.HasValue ? sessions.GetById(sessionId.Value) : null;
|
||
session ??= sessions.GetByGroup(groupId).FirstOrDefault(s => s.Date == today);
|
||
if (session is null && canEdit)
|
||
{
|
||
session = new ParticipationSession
|
||
{
|
||
GroupId = groupId,
|
||
Date = today,
|
||
Comment = "Sitzplan",
|
||
};
|
||
sessions.Save(session);
|
||
}
|
||
SessionDisplay = session is null ? "Keine Sitzung für heute" : $"{session.Date:dd.MM.yyyy} · {session.Comment}";
|
||
_entry = session is null ? null
|
||
: entries.GetBySessionAndStudent(session.Id, studentId)
|
||
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
|
||
|
||
var aspectDefinitions = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId)).ToList();
|
||
if (aspectDefinitions.Count == 0) aspectDefinitions = DefaultParticipationAspects.All.Select(a => new ParticipationAspect
|
||
{
|
||
Key = a.Key, Label = a.Label, ValueType = a.ValueType, MaxPoints = a.MaxPoints,
|
||
}).ToList();
|
||
foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i)))
|
||
{
|
||
var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value;
|
||
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating));
|
||
}
|
||
|
||
BuildAttendanceChoices();
|
||
BuildHomeworkChoices();
|
||
RefreshStatusChoices();
|
||
SelectAspect(0);
|
||
}
|
||
|
||
private void BuildAttendanceChoices()
|
||
{
|
||
AttendanceChoices.Add(new("✓", "Anwesend", "Strg+1", AttendanceStatus.Present, SetAttendance));
|
||
AttendanceChoices.Add(new("?", "Entschuldigung offen", "Strg+2", AttendanceStatus.ExcusePending, SetAttendance));
|
||
AttendanceChoices.Add(new("⊘", "Entschuldigt", "Strg+5", AttendanceStatus.Excused, SetAttendance));
|
||
AttendanceChoices.Add(new("◇", "Schulveranstaltung", "Strg+7", AttendanceStatus.OtherSchoolEvent, SetAttendance));
|
||
AttendanceChoices.Add(new("✕", "Geschwänzt", "Strg+9", AttendanceStatus.Truant, SetAttendance));
|
||
AttendanceChoices.Add(new("!", "Unentschuldigt", "Strg+0", AttendanceStatus.Unexcused, SetAttendance));
|
||
AttendanceChoices.Add(new("V", "Verspätet", "", AttendanceStatus.Late, SetAttendance));
|
||
AttendanceChoices.Add(new("V!", "Erheblich verspätet", "", AttendanceStatus.SignificantlyLate, SetAttendance));
|
||
AttendanceChoices.Add(new("A", "Im Unterricht abgängig", "", AttendanceStatus.LeftDuringClass, SetAttendance));
|
||
AttendanceChoices.Add(new("L", "Lerninsel", "", AttendanceStatus.LearningIsland, SetAttendance));
|
||
AttendanceChoices.Add(new("S", "Suspendiert", "", AttendanceStatus.Suspended, SetAttendance));
|
||
AttendanceChoices.Add(new("·", "Nicht kontrolliert", "Strg+X", null, SetAttendance));
|
||
}
|
||
|
||
private void BuildHomeworkChoices()
|
||
{
|
||
HomeworkChoices.Add(new("✓", "Gemacht", "⌥1", HomeworkStatus.Completed, SetHomework));
|
||
HomeworkChoices.Add(new("◐", "Teilweise", "⌥3", HomeworkStatus.PartiallyCompleted, SetHomework));
|
||
HomeworkChoices.Add(new("◕", "Rest nachgereicht", "⌥4", HomeworkStatus.PartialSubmittedLate, SetHomework));
|
||
HomeworkChoices.Add(new("◒", "Rest fehlt", "⌥5", HomeworkStatus.PartialMissingOverdue, SetHomework));
|
||
HomeworkChoices.Add(new("!", "Nicht gemacht", "⌥7", HomeworkStatus.MissingOpen, SetHomework));
|
||
HomeworkChoices.Add(new("↺", "Nachgereicht", "⌥8", HomeworkStatus.SubmittedLate, SetHomework));
|
||
HomeworkChoices.Add(new("✕", "Nicht nachgereicht", "⌥0", HomeworkStatus.MissingOverdue, SetHomework));
|
||
HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework));
|
||
}
|
||
|
||
public void SelectAspect(int index)
|
||
{
|
||
if (index < 0 || index >= AspectRows.Count) return;
|
||
SelectedAspectIndex = index;
|
||
foreach (var row in AspectRows) row.IsActive = row.Index == index;
|
||
}
|
||
|
||
public void MoveAspect(int delta)
|
||
{
|
||
if (AspectRows.Count == 0) return;
|
||
SelectAspect(Math.Clamp(SelectedAspectIndex + delta, 0, AspectRows.Count - 1));
|
||
}
|
||
|
||
public void SetRatingByNumber(int number)
|
||
{
|
||
if (!CanEdit) return;
|
||
var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex);
|
||
if (row is null) return;
|
||
if (row.ValueType == AspectValueType.Points)
|
||
row.ApplyValue(Math.Clamp(number, 0, row.MaxPoints));
|
||
else
|
||
{
|
||
var steps = ParticipationRatingScale.Steps(row.ValueType);
|
||
if (number >= 1 && number <= steps.Count) row.ApplyValue(steps[number - 1].Value);
|
||
}
|
||
}
|
||
|
||
public void AdjustCurrentRating(int delta)
|
||
{
|
||
if (!CanEdit) return;
|
||
var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex);
|
||
row?.Adjust(delta);
|
||
}
|
||
|
||
public void ClearCurrentRating()
|
||
{
|
||
if (!CanEdit) return;
|
||
AspectRows.ElementAtOrDefault(SelectedAspectIndex)?.ApplyValue(null);
|
||
}
|
||
|
||
public void ApplyAttendanceShortcut(int? digit, bool clear)
|
||
{
|
||
if (!CanEdit) return;
|
||
var status = clear ? null : digit switch
|
||
{
|
||
1 => AttendanceStatus.Present, 2 => AttendanceStatus.ExcusePending,
|
||
5 => AttendanceStatus.Excused, 7 => AttendanceStatus.OtherSchoolEvent,
|
||
9 => AttendanceStatus.Truant, 0 => AttendanceStatus.Unexcused,
|
||
_ => (AttendanceStatus?)null,
|
||
};
|
||
if (clear || digit is 0 or 1 or 2 or 5 or 7 or 9) SetAttendance(status);
|
||
}
|
||
|
||
public void ApplyHomeworkShortcut(int? digit, bool clear)
|
||
{
|
||
if (!CanEdit) return;
|
||
var status = clear ? null : digit switch
|
||
{
|
||
1 => HomeworkStatus.Completed, 3 => HomeworkStatus.PartiallyCompleted,
|
||
4 => HomeworkStatus.PartialSubmittedLate, 5 => HomeworkStatus.PartialMissingOverdue,
|
||
7 => HomeworkStatus.MissingOpen, 8 => HomeworkStatus.SubmittedLate,
|
||
0 => HomeworkStatus.MissingOverdue, _ => (HomeworkStatus?)null,
|
||
};
|
||
if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status);
|
||
}
|
||
|
||
private void ApplyRating(string key, int? value)
|
||
{
|
||
if (!CanEdit || _entry is null) return;
|
||
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);
|
||
}
|
||
|
||
private void SetAttendance(AttendanceStatus? status)
|
||
{
|
||
if (!CanEdit || _entry is null) return;
|
||
_entry.Attendance = status;
|
||
_entries.Save(_entry);
|
||
RefreshStatusChoices();
|
||
}
|
||
|
||
private void SetHomework(HomeworkStatus? status)
|
||
{
|
||
if (!CanEdit || _entry is null) return;
|
||
_entry.Homework = status;
|
||
_entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(status);
|
||
_entries.Save(_entry);
|
||
RefreshStatusChoices();
|
||
}
|
||
|
||
private void RefreshStatusChoices()
|
||
{
|
||
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
|
||
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
|
||
foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance;
|
||
var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry);
|
||
foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework;
|
||
}
|
||
}
|
||
|
||
public partial class SeatAssessmentAspectRow : ObservableObject
|
||
{
|
||
private readonly Action<string, int?> _apply;
|
||
[ObservableProperty] private bool _isActive;
|
||
[ObservableProperty] private int? _value;
|
||
public int Index { get; }
|
||
public string Key { get; }
|
||
public string Label { get; }
|
||
public AspectValueType ValueType { get; }
|
||
public int MaxPoints { get; }
|
||
public string Shortcut => Index switch { 0 => "Q", 1 => "W", 2 => "E", 3 => "R", 4 => "T", _ => "" };
|
||
public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value);
|
||
public ObservableCollection<SeatRatingChoice> Choices { get; } = [];
|
||
|
||
public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value,
|
||
Action<string, int?> apply)
|
||
{
|
||
Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
|
||
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
|
||
var steps = ValueType == AspectValueType.Points
|
||
? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList()
|
||
: ParticipationRatingScale.Steps(ValueType).ToList();
|
||
foreach (var (step, i) in steps.Select((s, i) => (s, i)))
|
||
Choices.Add(new SeatRatingChoice(step.Item2, ValueType == AspectValueType.Points ? step.Item1.ToString() : (i + 1).ToString(),
|
||
step.Item1, step.Item1 == value, ApplyValue));
|
||
}
|
||
|
||
public void ApplyValue(int? value)
|
||
{
|
||
Value = value;
|
||
OnPropertyChanged(nameof(DisplayValue));
|
||
foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
|
||
_apply(Key, value);
|
||
}
|
||
|
||
public void Adjust(int delta)
|
||
{
|
||
if (ValueType == AspectValueType.Points)
|
||
{
|
||
ApplyValue(Math.Clamp((Value ?? (delta > 0 ? -1 : MaxPoints + 1)) + delta, 0, MaxPoints));
|
||
return;
|
||
}
|
||
var steps = ParticipationRatingScale.Steps(ValueType).Select(s => s.Value).ToList();
|
||
if (steps.Count == 0) return;
|
||
var index = Value.HasValue ? steps.IndexOf(Value.Value) : (delta > 0 ? -1 : steps.Count);
|
||
ApplyValue(steps[Math.Clamp(index + delta, 0, steps.Count - 1)]);
|
||
}
|
||
|
||
[RelayCommand] private void Clear() => ApplyValue(null);
|
||
[RelayCommand] private void Increment() => Adjust(1);
|
||
[RelayCommand] private void Decrement() => Adjust(-1);
|
||
}
|
||
|
||
public partial class SeatRatingChoice(string label, string shortcut, int value, bool isSelected,
|
||
Action<int?> apply) : ObservableObject
|
||
{
|
||
public string Label { get; } = label;
|
||
public string Shortcut { get; } = shortcut;
|
||
public int Value { get; } = value;
|
||
[ObservableProperty] private bool _isSelected = isSelected;
|
||
[RelayCommand] private void Apply() => apply(Value);
|
||
}
|
||
|
||
public partial class SeatAttendanceChoice(string symbol, string label, string shortcut,
|
||
AttendanceStatus? status, Action<AttendanceStatus?> apply) : ObservableObject
|
||
{
|
||
public string Symbol { get; } = symbol;
|
||
public string Label { get; } = label;
|
||
public string Shortcut { get; } = shortcut;
|
||
public AttendanceStatus? Status { get; } = status;
|
||
[ObservableProperty] private bool _isSelected;
|
||
[RelayCommand] private void Apply() => apply(Status);
|
||
}
|
||
|
||
public partial class SeatHomeworkChoice(string symbol, string label, string shortcut,
|
||
HomeworkStatus? status, Action<HomeworkStatus?> apply) : ObservableObject
|
||
{
|
||
public string Symbol { get; } = symbol;
|
||
public string Label { get; } = label;
|
||
public string Shortcut { get; } = shortcut;
|
||
public HomeworkStatus? Status { get; } = status;
|
||
[ObservableProperty] private bool _isSelected;
|
||
[RelayCommand] private void Apply() => apply(Status);
|
||
}
|
||
|
||
public partial class SeatingPlanDialogViewModel : ObservableObject
|
||
{
|
||
private readonly ISeatingPlanRepository _plans;
|
||
private readonly Guid _groupId;
|
||
private readonly SeatingPlan? _editingPlan;
|
||
|
||
[ObservableProperty] private string _name = "";
|
||
[ObservableProperty] private string _room = "";
|
||
[ObservableProperty] private decimal _rows = 4;
|
||
[ObservableProperty] private decimal _columns = 4;
|
||
[ObservableProperty] private bool _isBoardAtBottom;
|
||
[ObservableProperty] private string _nameError = "";
|
||
[ObservableProperty] private string _layoutError = "";
|
||
|
||
public SeatingPlan? Result { get; private set; }
|
||
public ObservableCollection<ColumnGapEditor> ColumnGaps { get; } = [];
|
||
public string DialogTitle => _editingPlan is null ? "Neuen Sitzplan anlegen" : "Sitzplan bearbeiten";
|
||
public string SaveButtonText => _editingPlan is null ? "Anlegen" : "Speichern";
|
||
|
||
public SeatingPlanDialogViewModel(ISeatingPlanRepository plans, Guid groupId, SeatingPlan? editingPlan)
|
||
{
|
||
_plans = plans;
|
||
_groupId = groupId;
|
||
_editingPlan = editingPlan;
|
||
RebuildColumnGaps(decimal.ToInt32(Columns));
|
||
if (editingPlan is null) return;
|
||
Name = editingPlan.Name ?? "";
|
||
Room = editingPlan.Room ?? "";
|
||
Rows = editingPlan.Rows;
|
||
Columns = editingPlan.Columns;
|
||
IsBoardAtBottom = editingPlan.IsBoardAtBottom;
|
||
var savedGapWidths = editingPlan.ColumnGapWidths ?? [];
|
||
for (var i = 0; i < ColumnGaps.Count && i < savedGapWidths.Count; i++)
|
||
ColumnGaps[i].Width = (decimal)savedGapWidths[i];
|
||
}
|
||
|
||
partial void OnColumnsChanged(decimal value) => RebuildColumnGaps(decimal.ToInt32(value));
|
||
|
||
private void RebuildColumnGaps(int columns)
|
||
{
|
||
var previous = ColumnGaps.ToDictionary(g => g.AfterColumn, g => g.Width);
|
||
ColumnGaps.Clear();
|
||
for (var afterColumn = 1; afterColumn < columns; afterColumn++)
|
||
ColumnGaps.Add(new ColumnGapEditor(afterColumn, previous.GetValueOrDefault(afterColumn)));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
NameError = "";
|
||
LayoutError = "";
|
||
var valid = true;
|
||
if (string.IsNullOrWhiteSpace(Name))
|
||
{
|
||
NameError = "Bitte einen Namen eingeben.";
|
||
valid = false;
|
||
}
|
||
if (Rows is < 1 or > 10 || Columns is < 1 or > 10)
|
||
{
|
||
LayoutError = "Reihen und Plätze müssen zwischen 1 und 10 liegen.";
|
||
valid = false;
|
||
}
|
||
if (!valid) return;
|
||
|
||
var plan = _editingPlan ?? new SeatingPlan { GroupId = _groupId };
|
||
plan.Name = Name?.Trim() ?? "";
|
||
plan.Room = Room?.Trim() ?? "";
|
||
plan.Rows = decimal.ToInt32(Rows);
|
||
plan.Columns = decimal.ToInt32(Columns);
|
||
plan.IsBoardAtBottom = IsBoardAtBottom;
|
||
plan.ColumnGapWidths = ColumnGaps.Select(g => decimal.ToDouble(g.Width)).ToList();
|
||
try
|
||
{
|
||
_plans.Save(plan);
|
||
Result = plan;
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
NameError = ex.Message;
|
||
}
|
||
}
|
||
}
|
||
|
||
public partial class ColumnGapEditor(int afterColumn, decimal width) : ObservableObject
|
||
{
|
||
public int AfterColumn { get; } = afterColumn;
|
||
public string Label => $"Nach Platz {AfterColumn}";
|
||
[ObservableProperty] private decimal _width = width;
|
||
}
|