diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 4305302..d2eb948 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -299,6 +299,38 @@ public sealed class RepositoryTests Assert.Throws(() => new SeatingPlanRepository(db).Save(plan)); } + [Fact] + public void SeatingPlanRepository_AktualisiertPlanMitFehlendemOptionalenRaum() + { + using var db = NewInMemoryContext(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var student = new Student { FirstName = "Anna", LastName = "A" }; + db.Students.Insert(student); + db.Memberships.Insert(new GroupMembership { GroupId = group.Id, StudentId = student.Id }); + var plan = new SeatingPlan + { + GroupId = group.Id, + Name = "Standard", + Room = null!, + Rows = 2, + Columns = 2, + }; + db.SeatingPlans.Insert(plan); + plan.Assignments.Add(new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }); + + new SeatingPlanRepository(db).Save(plan); + + var saved = db.SeatingPlans.FindById(plan.Id); + Assert.NotNull(saved); + Assert.True(string.IsNullOrEmpty(saved.Room)); + Assert.Equal(student.Id, saved.Assignments.Single().StudentId); + + // LiteDB may materialize the empty optional value as null again. A later + // drag/drop save must therefore remain safe as well. + new SeatingPlanRepository(db).Save(saved); + } + [Fact] public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb() { diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 22c07dc..7fdb50c 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -162,8 +162,11 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository public void Save(SeatingPlan plan) { ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); - plan.Name = plan.Name.Trim(); - plan.Room = plan.Room.Trim(); + // LiteDB can deserialize missing/legacy optional string fields as null even + // though the current model initializes them with an empty string. + plan.Name = plan.Name?.Trim() ?? ""; + plan.Room = plan.Room?.Trim() ?? ""; + plan.Assignments ??= []; if (plan.Name.Length == 0) throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein."); if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10) diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index df955f7..e574611 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -78,8 +78,12 @@ public class FakeSessions(List all) : IParticipationSessio { public List GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList(); public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); - public void Save(ParticipationSession session) { } - public void Delete(Guid id) { } + public void Save(ParticipationSession session) + { + all.RemoveAll(s => s.Id == session.Id); + all.Add(session); + } + public void Delete(Guid id) => all.RemoveAll(s => s.Id == id); } public class FakeEntries : IParticipationRepository diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs index f2993a3..db17137 100644 --- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -25,7 +25,8 @@ public sealed class GroupDetailViewModelTests new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), new FakeCompetencyDomains(), students, new CompetencyAnalysisService()), - new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships)); + new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships, + new FakeSessions([]), new FakeEntries(), new FakeAspects())); vm.LoadGroup(group.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs index 105d4ab..504797d 100644 --- a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs @@ -24,7 +24,8 @@ public sealed class SeatingPlanViewModelTests Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }], }; var plans = new FakeSeatingPlans([plan]); - var vm = new SeatingPlanTabViewModel(plans, students, memberships); + var vm = new SeatingPlanTabViewModel(plans, students, memberships, + new FakeSessions([]), new FakeEntries(), new FakeAspects()); vm.Initialize(groupId, isReadOnly: false); vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id); @@ -40,7 +41,8 @@ public sealed class SeatingPlanViewModelTests var groupId = Guid.NewGuid(); var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 }; var vm = new SeatingPlanTabViewModel( - new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([])); + new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]), + new FakeSessions([]), new FakeEntries(), new FakeAspects()); vm.Initialize(groupId, isReadOnly: true); @@ -48,4 +50,74 @@ public sealed class SeatingPlanViewModelTests Assert.False(vm.Seats.Single().CanEdit); Assert.False(vm.AddPlanCommand.CanExecute(null)); } + + [Fact] + public void DragDrop_ZwischenBelegtenPlaetzen_TauschtDieSchueler() + { + var groupId = Guid.NewGuid(); + var anna = new Student { FirstName = "Anna", LastName = "A" }; + var ben = new Student { FirstName = "Ben", LastName = "B" }; + var students = new FakeStudents([anna, ben]); + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = groupId, StudentId = anna.Id }, + new GroupMembership { GroupId = groupId, StudentId = ben.Id }, + ]); + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2, + Assignments = + [ + new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id }, + new SeatAssignment { Row = 0, Column = 1, StudentId = ben.Id }, + ], + }; + var plans = new FakeSeatingPlans([plan]); + var vm = new SeatingPlanTabViewModel(plans, students, memberships, + new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + + vm.MoveSeat(vm.Seats[0], vm.Seats[1]); + + Assert.Equal(ben.Id, vm.Seats[0].SelectedOption.StudentId); + Assert.Equal(anna.Id, vm.Seats[1].SelectedOption.StudentId); + Assert.Equal(2, plans.GetById(plan.Id)!.Assignments.Count); + } + + [Fact] + public void SitzplatzBewertung_ErstelltHeutigeSitzungUndSpeichertAlleDreiBereiche() + { + var groupId = Guid.NewGuid(); + var studentId = Guid.NewGuid(); + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(), + groupId, studentId, "Beispiel, Anna", canEdit: true); + + vm.SetRatingByNumber(5); + vm.ApplyAttendanceShortcut(1, clear: false); + vm.ApplyHomeworkShortcut(7, clear: false); + + var session = Assert.Single(sessions.GetByGroup(groupId)); + Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date); + Assert.Equal("Sitzplan", session.Comment); + var entry = entries.GetBySessionAndStudent(session.Id, studentId)!; + Assert.Equal(2, entry.Ratings.Single(r => r.Key == "quality").Value); + Assert.Equal(AttendanceStatus.Present, entry.Attendance); + Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework); + Assert.True(entry.HomeworkMissing); + } + + [Fact] + public void SitzplatzBewertung_ArchiviertOhneSitzung_LegtKeineNeueSitzungAn() + { + var groupId = Guid.NewGuid(); + var sessions = new FakeSessions([]); + + var vm = new SeatAssessmentViewModel(sessions, new FakeEntries(), new FakeAspects(), + groupId, Guid.NewGuid(), "Beispiel, Anna", canEdit: false); + vm.SetRatingByNumber(5); + + Assert.False(vm.CanEdit); + Assert.Empty(sessions.GetByGroup(groupId)); + } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 034e995..bcd48cf 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -243,6 +243,11 @@ public partial class GroupDetailViewModel : ObservableObject PlanningTab = planningTab; CompetencyOverviewTab = competencyOverviewTab; SeatingPlanTab = seatingPlanTab; + SeatingPlanTab.OnAssessmentChanged = () => + { + ParticipationTab.LoadSessions(); + ParticipationTab.RefreshCurrentGrid(); + }; } public void LoadGroup(Guid id) diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index 385b284..19a7831 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -108,11 +108,11 @@ public partial class ParticipationTabViewModel : ObservableObject public void LoadSessions() { + var selectedId = SelectedSession?.Id; Sessions.Clear(); foreach (var s in _sessions.GetByGroup(_groupId)) Sessions.Add(new ParticipationSessionItem(s)); - if (SelectedSession is null && Sessions.Any()) - SelectedSession = Sessions[0]; + SelectedSession = Sessions.FirstOrDefault(s => s.Id == selectedId) ?? Sessions.FirstOrDefault(); } partial void OnSelectedSessionChanged(ParticipationSessionItem? value) diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index c4eb1a4..50343b3 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -12,6 +12,9 @@ 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 Guid _groupId; private SeatingPlan? _currentPlan; private bool _isReadOnly; @@ -25,19 +28,26 @@ public partial class SeatingPlanTabViewModel : ObservableObject public ObservableCollection Plans { get; } = []; public ObservableCollection Seats { get; } = []; public ObservableCollection StudentOptions { get; } = []; + public ObservableCollection UnassignedStudents { get; } = []; public bool HasPlans => Plans.Count > 0; public bool HasSelectedPlan => _currentPlan is not null; public bool IsEditable => !_isReadOnly; public Func>? OnEditPlan { get; set; } public Func>? OnConfirmDelete { get; set; } + public Func? OnAssessStudent { get; set; } + public Action? OnAssessmentChanged { get; set; } public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students, - IGroupMembershipRepository memberships) + IGroupMembershipRepository memberships, IParticipationSessionRepository sessions, + IParticipationRepository participation, IParticipationAspectRepository aspects) { _plans = plans; _students = students; _memberships = memberships; + _sessions = sessions; + _participation = participation; + _aspects = aspects; } public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) => @@ -87,6 +97,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject Seats.Clear(); if (plan is null) { + UnassignedStudents.Clear(); PlanColumns = 1; PlanTitle = ""; PlanSubtitle = ""; @@ -124,6 +135,49 @@ public partial class SeatingPlanTabViewModel : ObservableObject 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 (!IsEditable || 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 (!IsEditable || !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 (!IsEditable || !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 @@ -136,11 +190,13 @@ public partial class SeatingPlanTabViewModel : ObservableObject UpdateAssignmentSummary(); } - private void UpdateAssignmentSummary() + public async Task AssessStudent(SeatCellViewModel seat) { - var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue); - var total = StudentOptions.Count - 1; - AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet"; + 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); + await OnAssessStudent(assessment); + OnAssessmentChanged?.Invoke(); } [RelayCommand(CanExecute = nameof(CanEdit))] @@ -203,11 +259,14 @@ public partial class SeatCellViewModel : ObservableObject private bool _suppressChange; [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 Options { get; } public bool CanEdit { get; } + public bool IsOccupied => SelectedOption.StudentId.HasValue; + public string StudentName => IsOccupied ? SelectedOption.DisplayName : "Freier Platz"; public SeatCellViewModel(int row, int column, ObservableCollection options, StudentSeatOption selectedOption, Action onChanged, bool canEdit) @@ -222,6 +281,8 @@ public partial class SeatCellViewModel : ObservableObject partial void OnSelectedOptionChanged(StudentSeatOption value) { + OnPropertyChanged(nameof(IsOccupied)); + OnPropertyChanged(nameof(StudentName)); if (!_suppressChange) _onChanged(this); } @@ -233,6 +294,281 @@ public partial class SeatCellViewModel : ObservableObject } } +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 AspectRows { get; } = []; + public ObservableCollection AttendanceChoices { get; } = []; + public ObservableCollection HomeworkChoices { get; } = []; + + public SeatAssessmentViewModel(IParticipationSessionRepository sessions, + IParticipationRepository entries, IParticipationAspectRepository aspects, + Guid groupId, Guid studentId, string studentName, bool canEdit) + { + _entries = entries; + _canEdit = canEdit; + StudentName = studentName; + var today = DateOnly.FromDateTime(DateTime.Today); + var 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("·", "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 _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 Choices { get; } = []; + + public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value, + Action 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 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 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 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; @@ -256,8 +592,8 @@ public partial class SeatingPlanDialogViewModel : ObservableObject _groupId = groupId; _editingPlan = editingPlan; if (editingPlan is null) return; - Name = editingPlan.Name; - Room = editingPlan.Room; + Name = editingPlan.Name ?? ""; + Room = editingPlan.Room ?? ""; Rows = editingPlan.Rows; Columns = editingPlan.Columns; } @@ -281,8 +617,8 @@ public partial class SeatingPlanDialogViewModel : ObservableObject if (!valid) return; var plan = _editingPlan ?? new SeatingPlan { GroupId = _groupId }; - plan.Name = Name.Trim(); - plan.Room = Room.Trim(); + plan.Name = Name?.Trim() ?? ""; + plan.Room = Room?.Trim() ?? ""; plan.Rows = decimal.ToInt32(Rows); plan.Columns = decimal.ToInt32(Columns); try diff --git a/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml new file mode 100644 index 0000000..d2202e8 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml.cs new file mode 100644 index 0000000..74b0ba7 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml.cs @@ -0,0 +1,80 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using LehrerApp.Desktop.ViewModels.Groups; + +namespace LehrerApp.Desktop.Views.Groups; + +public partial class SeatAssessmentDialog : Window +{ + public SeatAssessmentDialog() + { + InitializeComponent(); + AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true); + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + Focus(); + } + + private void OnPreviewKeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not SeatAssessmentViewModel vm) return; + var digit = DigitFromEvent(e); + var clear = e.Key == Key.X || e.PhysicalKey == PhysicalKey.X; + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + if (clear || digit is not null) vm.ApplyAttendanceShortcut(digit, clear); + e.Handled = clear || digit is not null; + return; + } + if (e.KeyModifiers.HasFlag(KeyModifiers.Alt)) + { + if (clear || digit is not null) vm.ApplyHomeworkShortcut(digit, clear); + e.Handled = clear || digit is not null; + return; + } + if (digit is not null) + { + vm.SetRatingByNumber(digit.Value); + e.Handled = true; + return; + } + + switch (e.Key) + { + case Key.Q: vm.SelectAspect(0); e.Handled = true; break; + case Key.W: vm.SelectAspect(1); e.Handled = true; break; + case Key.E: vm.SelectAspect(2); e.Handled = true; break; + case Key.R: vm.SelectAspect(3); e.Handled = true; break; + case Key.T: vm.SelectAspect(4); e.Handled = true; break; + case Key.Left: vm.MoveAspect(-1); e.Handled = true; break; + case Key.Right: vm.MoveAspect(1); e.Handled = true; break; + case Key.OemPlus or Key.Add: vm.AdjustCurrentRating(1); e.Handled = true; break; + case Key.OemMinus or Key.Subtract: vm.AdjustCurrentRating(-1); e.Handled = true; break; + case Key.Back: vm.ClearCurrentRating(); e.Handled = true; break; + case Key.Escape: Close(); e.Handled = true; break; + } + } + + private static int? DigitFromEvent(KeyEventArgs e) => e.PhysicalKey switch + { + PhysicalKey.Digit0 => 0, PhysicalKey.Digit1 => 1, PhysicalKey.Digit2 => 2, + PhysicalKey.Digit3 => 3, PhysicalKey.Digit4 => 4, PhysicalKey.Digit5 => 5, + PhysicalKey.Digit6 => 6, PhysicalKey.Digit7 => 7, PhysicalKey.Digit8 => 8, + PhysicalKey.Digit9 => 9, + _ => e.Key switch + { + Key.D0 or Key.NumPad0 => 0, Key.D1 or Key.NumPad1 => 1, + Key.D2 or Key.NumPad2 => 2, Key.D3 or Key.NumPad3 => 3, + Key.D4 or Key.NumPad4 => 4, Key.D5 or Key.NumPad5 => 5, + Key.D6 or Key.NumPad6 => 6, Key.D7 or Key.NumPad7 => 7, + Key.D8 or Key.NumPad8 => 8, Key.D9 or Key.NumPad9 => 9, + _ => null, + }, + }; + + private void OnClose(object? sender, RoutedEventArgs e) => Close(); +} diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml index e969409..386549a 100644 --- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml @@ -3,6 +3,21 @@ xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanTabView" x:DataType="vm:SeatingPlanTabViewModel"> + + + + + @@ -51,8 +66,11 @@ - + + + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs index 2e181dd..01960b5 100644 --- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs @@ -1,4 +1,7 @@ using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; using LehrerApp.Core.Models; using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.Views.Shared; @@ -7,6 +10,15 @@ namespace LehrerApp.Desktop.Views.Groups; public partial class SeatingPlanTabView : UserControl { + private object? _dragCandidate; + private PointerPressedEventArgs? _dragTrigger; + private Avalonia.Point _pressPosition; + private SeatCellViewModel? _draggedSeat; + private StudentSeatOption? _draggedStudent; + private SeatCellViewModel? _pendingDropTarget; + private bool _pendingClearSeat; + private DateTime _ignoreTapUntil; + public SeatingPlanTabView() => InitializeComponent(); protected override void OnDataContextChanged(EventArgs e) @@ -16,9 +28,128 @@ public partial class SeatingPlanTabView : UserControl { vm.OnEditPlan = ShowPlanDialog; vm.OnConfirmDelete = ShowDeleteConfirmDialog; + vm.OnAssessStudent = ShowAssessmentDialog; } } + private void OnDragSourcePressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Control control || + !e.GetCurrentPoint(control).Properties.IsLeftButtonPressed) return; + var candidate = control.DataContext; + if (candidate is SeatCellViewModel { IsOccupied: false } or null) return; + if (DataContext is not SeatingPlanTabViewModel { IsEditable: true }) return; + _dragCandidate = candidate; + _dragTrigger = e; + _pressPosition = e.GetPosition(this); + } + + private async void OnDragSourceMoved(object? sender, PointerEventArgs e) + { + if (_dragCandidate is null || _dragTrigger is null || + !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; + var current = e.GetPosition(this); + if (Math.Abs(current.X - _pressPosition.X) < 6 && Math.Abs(current.Y - _pressPosition.Y) < 6) return; + + _draggedSeat = _dragCandidate as SeatCellViewModel; + _draggedStudent = _dragCandidate as StudentSeatOption; + _pendingDropTarget = null; + _pendingClearSeat = false; + _dragCandidate = null; + var trigger = _dragTrigger; + _dragTrigger = null; + var data = new DataTransfer(); + data.Add(DataTransferItem.CreateText("LehrerApp-Sitzplatz")); + var effect = await DragDrop.DoDragDropAsync(trigger, data, DragDropEffects.Move); + var draggedSeat = _draggedSeat; + var draggedStudent = _draggedStudent; + var dropTarget = _pendingDropTarget; + var clearSeat = _pendingClearSeat; + _draggedSeat = null; + _draggedStudent = null; + _pendingDropTarget = null; + _pendingClearSeat = false; + _ignoreTapUntil = DateTime.UtcNow.AddMilliseconds(250); + + // Never mutate an ItemsControl while Avalonia is still processing its native + // drop event. Assigning an unassigned student removes the dragged source item. + if ((effect & DragDropEffects.Move) == 0 || DataContext is not SeatingPlanTabViewModel vm) return; + Dispatcher.UIThread.Post(() => + { + if (clearSeat && draggedSeat is not null) vm.ClearSeat(draggedSeat); + else if (dropTarget is not null && draggedSeat is not null) vm.MoveSeat(draggedSeat, dropTarget); + else if (dropTarget is not null && draggedStudent is not null) vm.AssignStudent(draggedStudent, dropTarget); + }, DispatcherPriority.Background); + } + + private void OnDragSourceReleased(object? sender, PointerReleasedEventArgs e) + { + _dragCandidate = null; + _dragTrigger = null; + } + + private void OnSeatDragEnter(object? sender, DragEventArgs e) + { + if (sender is Border { DataContext: SeatCellViewModel target } && CanDropOn(target)) + target.IsDropTarget = true; + } + + private void OnSeatDragLeave(object? sender, DragEventArgs e) + { + if (sender is Border { DataContext: SeatCellViewModel target }) target.IsDropTarget = false; + } + + private void OnSeatDragOver(object? sender, DragEventArgs e) + { + e.DragEffects = sender is Border { DataContext: SeatCellViewModel target } && CanDropOn(target) + ? DragDropEffects.Move : DragDropEffects.None; + } + + private void OnSeatDrop(object? sender, DragEventArgs e) + { + if (sender is not Border { DataContext: SeatCellViewModel target }) return; + target.IsDropTarget = false; + if (!CanDropOn(target)) + { + e.DragEffects = DragDropEffects.None; + return; + } + _pendingDropTarget = target; + _pendingClearSeat = false; + e.DragEffects = DragDropEffects.Move; + } + + private bool CanDropOn(SeatCellViewModel target) => + DataContext is SeatingPlanTabViewModel { IsEditable: true } + && ((_draggedSeat is not null && _draggedSeat != target) || _draggedStudent?.StudentId is not null); + + private void OnUnassignedDragOver(object? sender, DragEventArgs e) => + e.DragEffects = _draggedSeat is not null ? DragDropEffects.Move : DragDropEffects.None; + + private void OnUnassignedDrop(object? sender, DragEventArgs e) + { + if (_draggedSeat is not null) + { + _pendingDropTarget = null; + _pendingClearSeat = true; + e.DragEffects = DragDropEffects.Move; + } + } + + private async void OnSeatTapped(object? sender, TappedEventArgs e) + { + if (DateTime.UtcNow < _ignoreTapUntil || sender is not Border { DataContext: SeatCellViewModel seat } + || !seat.IsOccupied || DataContext is not SeatingPlanTabViewModel vm) return; + await vm.AssessStudent(seat); + } + + private async Task ShowAssessmentDialog(SeatAssessmentViewModel vm) + { + var dialog = new SeatAssessmentDialog { DataContext = vm }; + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner is not null) await dialog.ShowDialog(owner); + } + private async Task ShowPlanDialog(SeatingPlan? plan) { if (DataContext is not SeatingPlanTabViewModel vm) return null; diff --git a/TODO.md b/TODO.md index 17e6a1d..ca2259e 100644 --- a/TODO.md +++ b/TODO.md @@ -1151,10 +1151,14 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen. die bereits vorhandenen getrennten Adressfelder. DOCX-Vorlagen verwenden Word- Inhaltssteuerelemente mit dokumentierten Tags wie `Letter.Salutation`, `Contact.Address`, `Student.FirstName` und `Group.Name`. -- [ ] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung. - Grundfunktion umgesetzt: mehrere benannte Sitzpläne je Lerngruppe und Raum, konfigurierbares - Raster sowie direkte Schülerzuordnung pro Platz. Offen bleiben Drag & Drop und der Sprung von - einem Sitzplatz in die Mitarbeitsbewertung. +- [x] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung. + Mehrere benannte Sitzpläne je Lerngruppe und Raum besitzen ein konfigurierbares Raster. + Schüler werden aus „Nicht zugeordnet“ auf Plätze gezogen, zwischen belegten Plätzen + getauscht oder durch Ablegen im freien Bereich wieder entfernt. Ein Klick auf einen + belegten Sitz öffnet die kompakte Sitzplatz-Schnelleingabe für alle aktiven + Mitarbeitsaspekte, Anwesenheit und Hausaufgaben. Die Eingabe unterstützt Maus sowie + Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert, + wird einmalig eine Sitzung „Sitzplan“ für heute angelegt. ### 7.2 Gruppen - [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/