CI / build-and-test (push) Canceled after 0s
- Mitarbeit: Tagesflagge (👑 Spitzentag / 😴 Schlaftag / ⚡ Schlechter Tag) je Schüler und Sitzung. Primär im Sitzplatz-Dialog (⇧1/2/3, ⇧X) mit Badge auf der Sitzplatz-Kachel; Fallback in der Schnelleingabe für Lerngruppen ohne Sitzplan. - Noten: neuer Dialog "Überblick" in der Notenübersicht zeigt Klausuren, Mitarbeit- und sonstige Noten eines Schülers samt berechneter Zeugnisnote. Zielnoten-Rechner (ReportGradeTargetCalculator) beantwortet "was brauche ich noch für Note X", ein Was-wäre-wenn-Rechner simuliert eine zusätzliche Klausurnote. Bewerter-/Schülermodus per Umschalter im selben Fenster — Bewertermodus zeigt zusätzlich Kursdurchschnitt je Klausur und erlaubt das Bearbeiten von Mitarbeit-/Sonstige-Noten. Klausurverlauf als Balken-Sparkline wie die bestehende Notenentwicklung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1108 lines
49 KiB
C#
1108 lines
49 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();
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// Prüft dabei bewusst gegen das Repository und nicht nur gegen das lokal geladene
|
||
/// <see cref="TodaySessions"/> / <see cref="SelectedSession"/>: Der Unterrichtsmodus
|
||
/// (<see cref="SelectOrCreateSessionForLesson"/>) läuft auf einer eigenen, per DI frisch
|
||
/// aufgelösten <see cref="SeatingPlanTabViewModel"/>-Instanz (siehe TimetableView.ShowTeachingMode),
|
||
/// getrennt von der Instanz, die im Sitzplan-Tab der Kursübersicht hängt. Eine dort bereits
|
||
/// heute angelegte (ggf. verknüpfte) Sitzung ist dieser Instanz also unbekannt, solange sie nur
|
||
/// im lokalen Feld nachschaut — das führte dazu, dass eine zweite, unverknüpfte "Sitzplan"-
|
||
/// Sitzung für denselben Tag entstand, sobald im echten Sitzplan-Tab bewertet wurde.
|
||
private ParticipationSessionOption? EnsureTodaySession()
|
||
{
|
||
if (SelectedSession is not null) return SelectedSession;
|
||
if (!IsEditable) return null;
|
||
|
||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||
var existing = _sessions.GetByGroup(_groupId).FirstOrDefault(s => s.Date == today);
|
||
if (existing is not null)
|
||
{
|
||
var found = TodaySessions.FirstOrDefault(o => o.Id == existing.Id);
|
||
if (found is null)
|
||
{
|
||
found = new ParticipationSessionOption(existing);
|
||
TodaySessions.Add(found);
|
||
}
|
||
SelectedSession = found;
|
||
return found;
|
||
}
|
||
|
||
var created = new ParticipationSession
|
||
{
|
||
GroupId = _groupId, Date = today, Comment = "Sitzplan",
|
||
};
|
||
_sessions.Save(created);
|
||
var option = new ParticipationSessionOption(created);
|
||
TodaySessions.Add(option);
|
||
SelectedSession = option;
|
||
return option;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Für den Unterrichtsmodus (14.x): wählt eine bereits mit dieser Stunde verknüpfte Sitzung
|
||
/// aus (angelegt z.B. über PlanningTabViewModel.CreateParticipationSession, 3.3.1) oder legt
|
||
/// bei Bedarf eine neue, EXPLIZIT verknüpfte an (<see cref="ParticipationSession.LessonId"/>).
|
||
/// Anders als <see cref="EnsureTodaySession"/> (anonyme "Sitzplan"-Sitzung, erst bei der
|
||
/// ersten tatsächlichen Aktion) ist das hier bewusst sofort beim Start des Unterrichtsmodus
|
||
/// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft
|
||
/// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest -
|
||
/// keine Geistersitzungs-Gefahr wie beim bloßen Öffnen eines Tabs.
|
||
/// </summary>
|
||
public void SelectOrCreateSessionForLesson(Lesson lesson)
|
||
{
|
||
var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id);
|
||
if (existing is not null) { SelectedSession = existing; return; }
|
||
if (!IsEditable) return;
|
||
|
||
var created = new ParticipationSession
|
||
{
|
||
GroupId = _groupId, Date = lesson.Date, LessonId = lesson.Id, Comment = lesson.Topic,
|
||
};
|
||
_sessions.Save(created);
|
||
var option = new ParticipationSessionOption(created);
|
||
TodaySessions.Add(option);
|
||
SelectedSession = 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();
|
||
var hiddenSeats = plan.HiddenSeats ?? [];
|
||
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;
|
||
var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column);
|
||
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
||
CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation));
|
||
}
|
||
UpdateAssignmentSummary();
|
||
RefreshSeatLessonData();
|
||
}
|
||
OnPropertyChanged(nameof(HasSelectedPlan));
|
||
NotifyCommands();
|
||
}
|
||
|
||
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis, siehe
|
||
/// UntisSyncService-Kommentar): der Unterrichtsmodus bewertet über eine zweite, unabhängige
|
||
/// ParticipationTabViewModel-Instanz (Schnellbewertungs-Dialoge, siehe TeachingModeWindow) —
|
||
/// diese Instanz hier bekommt davon nichts automatisch mit und muss nach jedem Dialog explizit
|
||
/// neu aus dem Repository laden, damit die Sitzplatz-Badges nicht veraltet bleiben.
|
||
public void ReloadSeatBadgesFromRepository() => RefreshSeatLessonData();
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// Strichliste im Sitzplan (Nutzer-Feedback): "drangekommen" ist immer auch eine Meldung und
|
||
/// erhöht daher beide Zähler — es gibt kein "drangekommen, ohne sich gemeldet zu haben".
|
||
private void TallyParticipation(SeatCellViewModel seat, bool calledOn)
|
||
{
|
||
if (!IsEditable || seat.SelectedOption.StudentId is not Guid studentId) return;
|
||
var session = EnsureTodaySession();
|
||
if (session is null) return;
|
||
|
||
var entry = _participation.GetBySessionAndStudent(session.Id, studentId)
|
||
?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId };
|
||
entry.RaisedHandCount++;
|
||
if (calledOn) entry.CalledOnCount++;
|
||
_participation.Save(entry);
|
||
RefreshSeatLessonData();
|
||
}
|
||
|
||
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 || target.IsHidden
|
||
|| !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 || target.IsHidden || !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();
|
||
}
|
||
|
||
/// <summary>Blendet einen leeren Platz aus/ein, für den im Raum physisch kein Tisch steht
|
||
/// (z. B. eine Reihe mit einer Lücke). Nur für leere Plätze möglich, siehe
|
||
/// SeatCellViewModel.CanToggleHidden.</summary>
|
||
public void ToggleSeatHidden(SeatCellViewModel seat)
|
||
{
|
||
if (!CanEditLayout || seat.IsOccupied) return;
|
||
seat.IsHidden = !seat.IsHidden;
|
||
SaveHiddenSeats();
|
||
}
|
||
|
||
private void SaveHiddenSeats()
|
||
{
|
||
if (_currentPlan is null) return;
|
||
_currentPlan.HiddenSeats = Seats
|
||
.Where(s => s.IsHidden)
|
||
.Select(s => new HiddenSeat { Row = s.Row, Column = s.Column })
|
||
.ToList();
|
||
_plans.Save(_currentPlan);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Klausur-Sitzplan mischen (14, Nutzer-Idee): erzeugt aus dem aktuell gewählten Plan einen
|
||
/// neuen, unabhängigen Plan mit gleichem Raster/Raum, aber zufällig vertauschten Insassen der
|
||
/// belegten Plätze (Fisher-Yates via <see cref="Random.Shared"/>) — die Platzkoordinaten selbst
|
||
/// bleiben unverändert, nur wer wo sitzt wird neu gewürfelt. Der ursprüngliche Plan bleibt
|
||
/// unangetastet erhalten, damit er bei Bedarf für den nächsten regulären Unterricht weiter
|
||
/// genutzt werden kann.
|
||
/// </summary>
|
||
[RelayCommand(CanExecute = nameof(CanEditSelected))]
|
||
private void ShuffleSeats()
|
||
{
|
||
if (_currentPlan is null) return;
|
||
|
||
var studentIds = _currentPlan.Assignments.Select(a => a.StudentId).ToArray();
|
||
Random.Shared.Shuffle(studentIds);
|
||
|
||
var shuffled = new SeatingPlan
|
||
{
|
||
GroupId = _currentPlan.GroupId,
|
||
Name = $"{_currentPlan.Name} (Klausur gemischt {DateTime.Now:dd.MM. HH:mm})",
|
||
Room = _currentPlan.Room,
|
||
Rows = _currentPlan.Rows,
|
||
Columns = _currentPlan.Columns,
|
||
ColumnGapWidths = [.. _currentPlan.ColumnGapWidths],
|
||
IsBoardAtBottom = _currentPlan.IsBoardAtBottom,
|
||
HiddenSeats = _currentPlan.HiddenSeats.Select(h => new HiddenSeat { Row = h.Row, Column = h.Column }).ToList(),
|
||
Assignments = _currentPlan.Assignments
|
||
.Select((a, i) => new SeatAssignment { Row = a.Row, Column = a.Column, StudentId = studentIds[i] })
|
||
.ToList(),
|
||
};
|
||
_plans.Save(shuffled);
|
||
ReloadPlans(shuffled.Id);
|
||
}
|
||
|
||
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();
|
||
ShuffleSeatsCommand.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;
|
||
private readonly Action<SeatCellViewModel> _toggleHidden;
|
||
|
||
[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 = "";
|
||
[ObservableProperty] private string _dayHighlightBadge = "";
|
||
[ObservableProperty] private int _raisedHandCount;
|
||
[ObservableProperty] private int _calledOnCount;
|
||
public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
|
||
public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
|
||
public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
|
||
public bool HasDayHighlightBadge => DayHighlightBadge.Length > 0;
|
||
public bool ShowLessonOverview => IsOccupied && !CanEdit;
|
||
[ObservableProperty] private bool _canRecordLesson;
|
||
public bool IsOccupied => SelectedOption.StudentId.HasValue;
|
||
public string StudentName => IsOccupied ? SelectedOption.DisplayName : IsHidden ? "Kein Tisch" : "Freier Platz";
|
||
/// <summary>Kein Tisch an dieser Position im Raum vorhanden (siehe SeatingPlan.HiddenSeats).
|
||
/// Bleibt außerhalb des Bearbeitungsmodus Teil des Rasters (für die Spaltenausrichtung), wird
|
||
/// dort aber nicht gerendert - siehe ShowSeat.</summary>
|
||
[ObservableProperty] private bool _isHidden;
|
||
/// <summary>Im Ansichtsmodus werden ausgeblendete Plätze nicht gerendert; im Bearbeitungsmodus
|
||
/// bleiben sie sichtbar (abgeblendet), damit sie wieder eingeblendet werden können.</summary>
|
||
public bool ShowSeat => !IsHidden || CanEdit;
|
||
/// <summary>Nur leere Plätze können ausgeblendet werden - ein belegter Platz müsste sonst
|
||
/// erst geräumt werden, was beim bloßen Ausblenden überraschend wäre.</summary>
|
||
public bool CanToggleHidden => CanEdit && !IsOccupied;
|
||
public string HiddenToggleLabel => IsHidden ? "Tisch einblenden" : "Kein Tisch hier";
|
||
/// <summary>Kombiniert LessonOpacity (Abwesenheits-Abblendung) mit dem Ausblenden-Zustand -
|
||
/// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal
|
||
/// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss
|
||
/// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen.</summary>
|
||
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
|
||
|
||
private readonly Action<SeatCellViewModel, bool> _tally;
|
||
|
||
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
|
||
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
|
||
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false,
|
||
bool isHidden = false, Action<SeatCellViewModel>? toggleHidden = null,
|
||
Action<SeatCellViewModel, bool>? tally = null)
|
||
{
|
||
Row = row;
|
||
Column = column;
|
||
Options = options;
|
||
_selectedOption = selectedOption;
|
||
_onChanged = onChanged;
|
||
_canEdit = canEdit;
|
||
_toggleSituationTag = toggleSituationTag ?? ((_, _) => { });
|
||
_canRecordLesson = canRecordLesson;
|
||
_isHidden = isHidden;
|
||
_toggleHidden = toggleHidden ?? (_ => { });
|
||
_tally = tally ?? ((_, _) => { });
|
||
foreach (var tag in SituationTagChoice.DefaultTags)
|
||
SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value)));
|
||
}
|
||
|
||
/// Strichliste im Sitzplan (Nutzer-Feedback): schnelles Mitzählen während des Unterrichts,
|
||
/// ohne dafür den vollen Sitzplatz-Bewertungsdialog öffnen zu müssen — bewusst nur ein
|
||
/// Hochzählen ohne Korrekturmöglichkeit hier direkt auf der Kachel; die Rohwerte bleiben über
|
||
/// den Bewertungsdialog einsehbar und fließen dort als Quantitäts-Vorschlag ein.
|
||
[RelayCommand] private void TallyRaisedHand() => _tally(this, false);
|
||
[RelayCommand] private void TallyCalledOn() => _tally(this, true);
|
||
|
||
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
||
{
|
||
OnPropertyChanged(nameof(IsOccupied));
|
||
OnPropertyChanged(nameof(StudentName));
|
||
OnPropertyChanged(nameof(ShowLessonOverview));
|
||
OnPropertyChanged(nameof(CanToggleHidden));
|
||
if (!_suppressChange) _onChanged(this);
|
||
}
|
||
|
||
partial void OnCanEditChanged(bool value)
|
||
{
|
||
OnPropertyChanged(nameof(ShowLessonOverview));
|
||
OnPropertyChanged(nameof(ShowSeat));
|
||
OnPropertyChanged(nameof(CanToggleHidden));
|
||
}
|
||
|
||
partial void OnIsHiddenChanged(bool value)
|
||
{
|
||
OnPropertyChanged(nameof(ShowSeat));
|
||
OnPropertyChanged(nameof(StudentName));
|
||
OnPropertyChanged(nameof(DisplayOpacity));
|
||
OnPropertyChanged(nameof(HiddenToggleLabel));
|
||
}
|
||
|
||
partial void OnLessonOpacityChanged(double value) => OnPropertyChanged(nameof(DisplayOpacity));
|
||
|
||
[RelayCommand]
|
||
private void ToggleHidden() => _toggleHidden(this);
|
||
|
||
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));
|
||
DayHighlightBadge = DayHighlightDisplay.Symbol(entry?.DayHighlight);
|
||
RaisedHandCount = entry?.RaisedHandCount ?? 0;
|
||
CalledOnCount = entry?.CalledOnCount ?? 0;
|
||
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));
|
||
OnPropertyChanged(nameof(HasDayHighlightBadge));
|
||
}
|
||
}
|
||
|
||
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";
|
||
[ObservableProperty] private string _dayHighlightLabel = "Keine Markierung";
|
||
|
||
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 ObservableCollection<SeatDayHighlightChoice> DayHighlightChoices { 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;
|
||
// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback): nur für den
|
||
// eingebauten "quantity"-Aspekt, siehe ParticipationCountSuggestion.
|
||
var raisedHandCount = aspect.Key == "quantity" ? _entry?.RaisedHandCount : null;
|
||
AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating, raisedHandCount));
|
||
}
|
||
|
||
BuildAttendanceChoices();
|
||
BuildHomeworkChoices();
|
||
BuildDayHighlightChoices();
|
||
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));
|
||
}
|
||
|
||
private void BuildDayHighlightChoices()
|
||
{
|
||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Standout),
|
||
DayHighlightDisplay.Label(DayHighlightKind.Standout), "⇧1", DayHighlightKind.Standout, SetDayHighlight));
|
||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Sleepy),
|
||
DayHighlightDisplay.Label(DayHighlightKind.Sleepy), "⇧2", DayHighlightKind.Sleepy, SetDayHighlight));
|
||
DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Rough),
|
||
DayHighlightDisplay.Label(DayHighlightKind.Rough), "⇧3", DayHighlightKind.Rough, SetDayHighlight));
|
||
DayHighlightChoices.Add(new("·", "Keine Markierung", "⇧X", null, SetDayHighlight));
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
public void ApplyDayHighlightShortcut(int? digit, bool clear)
|
||
{
|
||
if (!CanEdit) return;
|
||
var kind = clear ? null : digit switch
|
||
{
|
||
1 => DayHighlightKind.Standout, 2 => DayHighlightKind.Sleepy, 3 => DayHighlightKind.Rough,
|
||
_ => (DayHighlightKind?)null,
|
||
};
|
||
if (clear || digit is 1 or 2 or 3) SetDayHighlight(kind);
|
||
}
|
||
|
||
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 SetDayHighlight(DayHighlightKind? kind)
|
||
{
|
||
if (!CanEdit || _entry is null) return;
|
||
_entry.DayHighlight = kind;
|
||
_entries.Save(_entry);
|
||
RefreshStatusChoices();
|
||
}
|
||
|
||
private void RefreshStatusChoices()
|
||
{
|
||
AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance);
|
||
HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry));
|
||
DayHighlightLabel = DayHighlightDisplay.Label(_entry?.DayHighlight);
|
||
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;
|
||
foreach (var choice in DayHighlightChoices) choice.IsSelected = choice.Kind == _entry?.DayHighlight;
|
||
}
|
||
}
|
||
|
||
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; } = [];
|
||
|
||
/// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback), siehe
|
||
/// ParticipationCountSuggestion — null, wenn keine Strichliste zu diesem Aspekt gehört.
|
||
private readonly int? _raisedHandCount;
|
||
public int? SuggestedValue { get; }
|
||
public bool HasSuggestion => SuggestedValue.HasValue && SuggestedValue != Value;
|
||
public string SuggestionLabel =>
|
||
$"{_raisedHandCount}× gemeldet → Vorschlag: {ParticipationRatingScale.DisplayLabel(ValueType, SuggestedValue)}";
|
||
|
||
public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value,
|
||
Action<string, int?> apply, int? raisedHandCount = null)
|
||
{
|
||
Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType;
|
||
MaxPoints = aspect.MaxPoints; _value = value; _apply = apply;
|
||
_raisedHandCount = raisedHandCount;
|
||
SuggestedValue = raisedHandCount is int rhc
|
||
? ParticipationCountSuggestion.SuggestQuantity(ValueType, rhc) : null;
|
||
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));
|
||
OnPropertyChanged(nameof(HasSuggestion));
|
||
foreach (var choice in Choices) choice.IsSelected = choice.Value == value;
|
||
_apply(Key, value);
|
||
}
|
||
|
||
[RelayCommand] private void ApplySuggestion() => ApplyValue(SuggestedValue);
|
||
|
||
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 SeatDayHighlightChoice(string symbol, string label, string shortcut,
|
||
DayHighlightKind? kind, Action<DayHighlightKind?> apply) : ObservableObject
|
||
{
|
||
public string Symbol { get; } = symbol;
|
||
public string Label { get; } = label;
|
||
public string Shortcut { get; } = shortcut;
|
||
public DayHighlightKind? Kind { get; } = kind;
|
||
[ObservableProperty] private bool _isSelected;
|
||
[RelayCommand] private void Apply() => apply(Kind);
|
||
}
|
||
|
||
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;
|
||
}
|