Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs
T
adminandClaude Sonnet 5 52a1d45539 Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes
Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht
gemachter Hausaufgabe aus der letzten Mitarbeitssitzung.

Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von
reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse
machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard,
läuft nicht in die Zeitauswertung ein.

Drei Bugfixes aus Nutzer-Feedback:
- Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung
  erzeugen" mit einer Mitarbeitssitzung verknüpft war.
- Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei
  echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an.
- Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag
  eine Lesson existierte, statt schon laut Stundenplan.

Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift
(Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante
ausweichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:17:18 +02:00

857 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
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();
}
/// Legt bewusst KEINE Sitzung an, nur weil der Tab geöffnet wird — Initialize() läuft für
/// jede Gruppe schon beim bloßen Navigieren zur Kursübersicht (GroupDetailViewModel.LoadGroup),
/// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Eine Sitzung entsteht erst
/// bei der ersten tatsächlichen Bewertung/Markierung, siehe EnsureTodaySession().
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();
foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session));
SelectedSession = TodaySessions.LastOrDefault();
}
/// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen
/// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/
/// ToggleSituationTag), nicht schon beim Öffnen des Tabs.
private ParticipationSessionOption? EnsureTodaySession()
{
if (SelectedSession is not null) return SelectedSession;
if (!IsEditable) return null;
var created = new ParticipationSession
{
GroupId = _groupId, Date = DateOnly.FromDateTime(DateTime.Today), Comment = "Sitzplan",
};
_sessions.Save(created);
var option = new ParticipationSessionOption(created);
TodaySessions.Add(option);
SelectedSession = option;
return option;
}
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 ||
seat.SelectedOption.StudentId is not Guid studentId) return;
var session = EnsureTodaySession();
if (session is null) return;
var draft = _documentation.GetByStudent(studentId)
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == session.Id);
if (draft is null)
{
draft = new Documentation
{
StudentId = studentId, GroupId = _groupId,
ParticipationSessionId = session.Id,
LessonId = session.LessonId,
Type = DocumentationType.Incident,
Date = session.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 session = EnsureTodaySession();
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
session?.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;
}