feat: add seating plans for learning groups

This commit is contained in:
2026-08-17 01:01:40 +02:00
parent fbf70df439
commit fe36f51886
19 changed files with 771 additions and 6 deletions
@@ -19,6 +19,13 @@ public interface IGroupRepository
void Save(LearningGroup group); void Save(LearningGroup group);
void Delete(Guid id); void Delete(Guid id);
} }
public interface ISeatingPlanRepository
{
SeatingPlan? GetById(Guid id);
List<SeatingPlan> GetByGroup(Guid groupId);
void Save(SeatingPlan plan);
void Delete(Guid id);
}
public interface IGroupMembershipRepository public interface IGroupMembershipRepository
{ {
List<GroupMembership> GetByStudent(Guid studentId); List<GroupMembership> GetByStudent(Guid studentId);
+25
View File
@@ -0,0 +1,25 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Ein Sitzplan einer Lerngruppe. Eine Gruppe kann mehrere Pläne besitzen,
/// beispielsweise für unterschiedliche Unterrichtsräume.
/// </summary>
public class SeatingPlan
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public string Name { get; set; } = "";
public string Room { get; set; } = "";
public int Rows { get; set; } = 4;
public int Columns { get; set; } = 4;
public List<SeatAssignment> Assignments { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class SeatAssignment
{
public int Row { get; set; }
public int Column { get; set; }
public Guid StudentId { get; set; }
}
@@ -21,7 +21,8 @@ public class PersonalDataExportService(
IGradeRepository grades, IGradeRepository grades,
IExamResultRepository examResults, IExamResultRepository examResults,
IParticipationRepository participation, IParticipationRepository participation,
IDocumentationRepository documentation) IDocumentationRepository documentation,
ISeatingPlanRepository seatingPlans)
{ {
public string ExportAsJson(Guid studentId) public string ExportAsJson(Guid studentId)
{ {
@@ -43,6 +44,18 @@ public class PersonalDataExportService(
Klausurergebnisse = examResults.GetByStudent(studentId), Klausurergebnisse = examResults.GetByStudent(studentId),
Mitarbeit = participation.GetByStudent(studentId), Mitarbeit = participation.GetByStudent(studentId),
Dokumentation = documentation.GetByStudent(studentId), Dokumentation = documentation.GetByStudent(studentId),
Sitzplaetze = studentGroups
.SelectMany(g => seatingPlans.GetByGroup(g!.Id))
.SelectMany(plan => plan.Assignments
.Where(a => a.StudentId == studentId)
.Select(a => new
{
Sitzplan = plan.Name,
plan.Room,
Reihe = a.Row + 1,
Platz = a.Column + 1,
}))
.ToList(),
}; };
return JsonSerializer.Serialize(dto, new JsonSerializerOptions return JsonSerializer.Serialize(dto, new JsonSerializerOptions
+58
View File
@@ -189,6 +189,9 @@ public sealed class RepositoryTests
var sectionId = Guid.NewGuid(); var sectionId = Guid.NewGuid();
db.ParticipationSections.Insert(new ParticipationSection { Id = sectionId, GroupId = groupId }); db.ParticipationSections.Insert(new ParticipationSection { Id = sectionId, GroupId = groupId });
var seatingPlanId = Guid.NewGuid();
db.SeatingPlans.Insert(new SeatingPlan { Id = seatingPlanId, GroupId = groupId, Name = "Raumplan" });
var documentationId = Guid.NewGuid(); var documentationId = Guid.NewGuid();
db.Documentation.Insert(new Documentation { Id = documentationId, GroupId = groupId, StudentId = studentId }); db.Documentation.Insert(new Documentation { Id = documentationId, GroupId = groupId, StudentId = studentId });
@@ -214,6 +217,7 @@ public sealed class RepositoryTests
Assert.Null(db.ReportGrades.FindById(reportGradeId)); Assert.Null(db.ReportGrades.FindById(reportGradeId));
Assert.Null(db.GradingSchemes.FindById(gradingSchemeId)); Assert.Null(db.GradingSchemes.FindById(gradingSchemeId));
Assert.Null(db.ParticipationSections.FindById(sectionId)); Assert.Null(db.ParticipationSections.FindById(sectionId));
Assert.Null(db.SeatingPlans.FindById(seatingPlanId));
Assert.Null(db.Documentation.FindById(documentationId)?.GroupId); Assert.Null(db.Documentation.FindById(documentationId)?.GroupId);
Assert.Null(db.Tasks.FindById(taskId)?.GroupId); Assert.Null(db.Tasks.FindById(taskId)?.GroupId);
Assert.Null(db.TimeEntries.FindById(timeEntryId)?.GroupId); Assert.Null(db.TimeEntries.FindById(timeEntryId)?.GroupId);
@@ -241,6 +245,60 @@ public sealed class RepositoryTests
// ── GroupMembershipRepository ──────────────────────────────────────────── // ── GroupMembershipRepository ────────────────────────────────────────────
[Fact]
public void SeatingPlanRepository_SpeichertMehrerePlaeneUndEindeutigeZuordnungen()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var anna = new Student { FirstName = "Anna", LastName = "A" };
var ben = new Student { FirstName = "Ben", LastName = "B" };
db.Students.InsertBulk([anna, ben]);
db.Memberships.InsertBulk([
new GroupMembership { GroupId = group.Id, StudentId = anna.Id },
new GroupMembership { GroupId = group.Id, StudentId = ben.Id },
]);
var repo = new SeatingPlanRepository(db);
repo.Save(new SeatingPlan
{
GroupId = group.Id, Name = "Standard", Room = "B204", Rows = 2, Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id }],
});
repo.Save(new SeatingPlan
{
GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3,
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }],
});
var plans = repo.GetByGroup(group.Id);
Assert.Equal(2, plans.Count);
Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id);
Assert.Contains(plans, p => p.Room == "Physik" && p.Assignments.Single().StudentId == ben.Id);
}
[Fact]
public void SeatingPlanRepository_LehntDoppeltZugeordnetenSchuelerAb()
{
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", Rows = 2, Columns = 2,
Assignments =
[
new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id },
new SeatAssignment { Row = 0, Column = 1, StudentId = student.Id },
],
};
Assert.Throws<InvalidOperationException>(() => new SeatingPlanRepository(db).Save(plan));
}
[Fact] [Fact]
public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb() public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb()
{ {
+2
View File
@@ -36,6 +36,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students"); public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups"); public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
public ILiteCollection<SeatingPlan> SeatingPlans => _db.GetCollection<SeatingPlan>("seating_plans");
public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships"); public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships");
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams"); public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results"); public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
@@ -333,6 +334,7 @@ public class LiteDbContext : IDisposable
Students.EnsureIndex(x => x.IsActive); Students.EnsureIndex(x => x.IsActive);
Groups.EnsureIndex(x => x.SchoolYear); Groups.EnsureIndex(x => x.SchoolYear);
Groups.EnsureIndex(x => x.IsActive); Groups.EnsureIndex(x => x.IsActive);
SeatingPlans.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex(x => x.StudentId); Memberships.EnsureIndex(x => x.StudentId);
Memberships.EnsureIndex(x => x.GroupId); Memberships.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex("ux_student_group", Memberships.EnsureIndex("ux_student_group",
@@ -121,6 +121,9 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList()) foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList())
db.ParticipationSections.Delete(section.Id); db.ParticipationSections.Delete(section.Id);
foreach (var plan in db.SeatingPlans.Find(p => p.GroupId == id).ToList())
db.SeatingPlans.Delete(plan.Id);
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten, // Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt. // werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList()) foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList())
@@ -148,6 +151,58 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
} }
} }
public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
{
public SeatingPlan? GetById(Guid id) => db.SeatingPlans.FindById(id);
public List<SeatingPlan> GetByGroup(Guid groupId) =>
db.SeatingPlans.Find(p => p.GroupId == groupId)
.OrderBy(p => p.Name).ThenBy(p => p.Room).ToList();
public void Save(SeatingPlan plan)
{
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
plan.Name = plan.Name.Trim();
plan.Room = plan.Room.Trim();
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)
throw new ArgumentOutOfRangeException(nameof(plan), "Ein Sitzplan muss zwischen 1 und 10 Reihen und Spalten haben.");
if (db.Groups.FindById(plan.GroupId) is null)
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId)
.FirstOrDefault(p => p.Id != plan.Id
&& string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)
&& string.Equals(p.Room, plan.Room, StringComparison.OrdinalIgnoreCase));
if (duplicateName is not null)
throw new InvalidOperationException("Für diese Lerngruppe existiert bereits ein gleichnamiger Sitzplan in diesem Raum.");
plan.Assignments = plan.Assignments
.Where(a => a.Row >= 0 && a.Row < plan.Rows && a.Column >= 0 && a.Column < plan.Columns)
.ToList();
if (plan.Assignments.GroupBy(a => (a.Row, a.Column)).Any(g => g.Count() > 1))
throw new InvalidOperationException("Ein Sitzplatz darf nur einmal belegt werden.");
if (plan.Assignments.GroupBy(a => a.StudentId).Any(g => g.Count() > 1))
throw new InvalidOperationException("Ein Schüler darf in einem Sitzplan nur einmal vorkommen.");
var memberIds = db.Memberships.Find(m => m.GroupId == plan.GroupId)
.Select(m => m.StudentId).ToHashSet();
if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId)))
throw new InvalidOperationException("Der Sitzplan enthält einen Schüler, der nicht zur Lerngruppe gehört.");
plan.UpdatedAt = DateTime.UtcNow;
db.SeatingPlans.Upsert(plan);
}
public void Delete(Guid id)
{
if (db.SeatingPlans.FindById(id) is { } plan)
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
db.SeatingPlans.Delete(id);
}
}
public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository
{ {
public List<GroupMembership> GetByStudent(Guid id) => public List<GroupMembership> GetByStudent(Guid id) =>
+14
View File
@@ -60,6 +60,20 @@ public class FakeMemberships(List<GroupMembership> all) : IGroupMembershipReposi
public void Delete(Guid id) => all.RemoveAll(m => m.Id == id); public void Delete(Guid id) => all.RemoveAll(m => m.Id == id);
} }
public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanRepository
{
private readonly List<SeatingPlan> _all = initial ?? [];
public SeatingPlan? GetById(Guid id) => _all.FirstOrDefault(p => p.Id == id);
public List<SeatingPlan> GetByGroup(Guid groupId) =>
_all.Where(p => p.GroupId == groupId).OrderBy(p => p.Name).ToList();
public void Save(SeatingPlan plan)
{
_all.RemoveAll(p => p.Id == plan.Id);
_all.Add(plan);
}
public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id);
}
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
{ {
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList(); public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
@@ -24,7 +24,8 @@ public sealed class GroupDetailViewModelTests
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()), new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService())); new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships));
vm.LoadGroup(group.Id); vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -24,13 +24,23 @@ public sealed class PersonalDataExportServiceTests
var documentation = new FakeDocumentation(); var documentation = new FakeDocumentation();
documentation.Add(new Documentation { StudentId = studentId, Title = "Elterngespräch" }); documentation.Add(new Documentation { StudentId = studentId, Title = "Elterngespräch" });
var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries, documentation); var seatingPlans = new FakeSeatingPlans([
new SeatingPlan
{
GroupId = groupId, Name = "Standard", Room = "B204",
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = studentId }],
},
]);
var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries,
documentation, seatingPlans);
var json = service.ExportAsJson(studentId); var json = service.ExportAsJson(studentId);
Assert.Contains("Anna", json); Assert.Contains("Anna", json);
Assert.Contains("Elterngespräch", json); Assert.Contains("Elterngespräch", json);
Assert.Contains("\"Value\": \"2\"", json); Assert.Contains("\"Value\": \"2\"", json);
Assert.Contains("\"Sitzplan\": \"Standard\"", json);
Assert.Contains("\"Reihe\": 2", json);
} }
[Fact] [Fact]
@@ -38,7 +48,8 @@ public sealed class PersonalDataExportServiceTests
{ {
var service = new PersonalDataExportService( var service = new PersonalDataExportService(
new FakeStudents([]), new FakeGroups([]), new FakeMemberships([]), new FakeStudents([]), new FakeGroups([]), new FakeMemberships([]),
new FakeGrades(), new FakeResults(), new FakeEntries(), new FakeDocumentation()); new FakeGrades(), new FakeResults(), new FakeEntries(), new FakeDocumentation(),
new FakeSeatingPlans());
Assert.Throws<InvalidOperationException>(() => service.ExportAsJson(Guid.NewGuid())); Assert.Throws<InvalidOperationException>(() => service.ExportAsJson(Guid.NewGuid()));
} }
@@ -0,0 +1,51 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SeatingPlanViewModelTests
{
[Fact]
public void AuswahlEinesBereitsZugeordnetenSchuelers_VerschiebtIhnAufDenNeuenPlatz()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([student]);
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = student.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId,
Name = "Standard",
Rows = 1,
Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, students, memberships);
vm.Initialize(groupId, isReadOnly: false);
vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id);
Assert.Null(vm.Seats[0].SelectedOption.StudentId);
var assignment = Assert.Single(plans.GetById(plan.Id)!.Assignments);
Assert.Equal((0, 1, student.Id), (assignment.Row, assignment.Column, assignment.StudentId));
}
[Fact]
public void ArchivierteGruppe_DeaktiviertSitzplatzbearbeitung()
{
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([]));
vm.Initialize(groupId, isReadOnly: true);
Assert.False(vm.IsEditable);
Assert.False(vm.Seats.Single().CanEdit);
Assert.False(vm.AddPlanCommand.CanExecute(null));
}
}
+2
View File
@@ -118,6 +118,7 @@ public static class AppBootstrapper
// ── Repositories ────────────────────────────────────────────────────── // ── Repositories ──────────────────────────────────────────────────────
services.AddSingleton<IStudentRepository, StudentRepository>(); services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>(); services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>(); services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
services.AddSingleton<IExamRepository, ExamRepository>(); services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>(); services.AddSingleton<IExamResultRepository, ExamResultRepository>();
@@ -216,6 +217,7 @@ public static class AppBootstrapper
services.AddTransient<GradeOverviewTabViewModel>(); services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<PlanningTabViewModel>(); services.AddTransient<PlanningTabViewModel>();
services.AddTransient<CompetencyOverviewTabViewModel>(); services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
@@ -208,7 +208,7 @@ public partial class GroupDetailViewModel : ObservableObject
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents(); partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
partial void OnActiveTabIndexChanged(int value) partial void OnActiveTabIndexChanged(int value)
{ {
if (value == 6) CompetencyOverviewTab.Refresh(); if (value == 7) CompetencyOverviewTab.Refresh();
} }
public ObservableCollection<StudentSummary> Students { get; } = []; public ObservableCollection<StudentSummary> Students { get; } = [];
@@ -218,6 +218,7 @@ public partial class GroupDetailViewModel : ObservableObject
public GradeOverviewTabViewModel GradeOverviewTab { get; } public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; } public PlanningTabViewModel PlanningTab { get; }
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; } public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; } public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; } public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; } public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -232,7 +233,8 @@ public partial class GroupDetailViewModel : ObservableObject
IGroupMembershipRepository memberships, ISubjectRepository subjects, IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab) PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab)
{ {
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks; _exams = exams; _grades = grades; _tasks = tasks;
@@ -240,6 +242,7 @@ public partial class GroupDetailViewModel : ObservableObject
GradeOverviewTab = gradeOverviewTab; GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab; PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab; CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab;
} }
public void LoadGroup(Guid id) public void LoadGroup(Guid id)
@@ -259,6 +262,7 @@ public partial class GroupDetailViewModel : ObservableObject
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly); GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
PlanningTab.Initialize(Group.Id, IsReadOnly); PlanningTab.Initialize(Group.Id, IsReadOnly);
CompetencyOverviewTab.Initialize(Group); CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
} }
private void ReloadExams() private void ReloadExams()
@@ -0,0 +1,298 @@
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 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 = "";
public ObservableCollection<SeatingPlanSummary> Plans { get; } = [];
public ObservableCollection<SeatCellViewModel> Seats { get; } = [];
public ObservableCollection<StudentSeatOption> StudentOptions { get; } = [];
public bool HasPlans => Plans.Count > 0;
public bool HasSelectedPlan => _currentPlan is not null;
public bool IsEditable => !_isReadOnly;
public Func<SeatingPlan?, Task<SeatingPlan?>>? OnEditPlan { get; set; }
public Func<SeatingPlanSummary, Task<bool>>? OnConfirmDelete { get; set; }
public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students,
IGroupMembershipRepository memberships)
{
_plans = plans;
_students = students;
_memberships = memberships;
}
public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) =>
new(_plans, _groupId, plan);
public void Initialize(Guid groupId, bool isReadOnly)
{
_groupId = groupId;
_isReadOnly = isReadOnly;
LoadStudentOptions();
ReloadPlans();
OnPropertyChanged(nameof(IsEditable));
NotifyCommands();
}
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)
{
PlanColumns = 1;
PlanTitle = "";
PlanSubtitle = "";
AssignmentSummary = "";
}
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";
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, IsEditable));
}
UpdateAssignmentSummary();
}
OnPropertyChanged(nameof(HasSelectedPlan));
NotifyCommands();
}
private void OnSeatChanged(SeatCellViewModel changed)
{
if (_currentPlan is null || !IsEditable) 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);
}
_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();
}
private void UpdateAssignmentSummary()
{
var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue);
var total = StudentOptions.Count - 1;
AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet";
}
[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();
}
private bool CanEdit() => IsEditable;
private bool CanEditSelected() => IsEditable && _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 partial class SeatCellViewModel : ObservableObject
{
private readonly Action<SeatCellViewModel> _onChanged;
private bool _suppressChange;
[ObservableProperty] private StudentSeatOption _selectedOption;
public int Row { get; }
public int Column { get; }
public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}";
public ObservableCollection<StudentSeatOption> Options { get; }
public bool CanEdit { get; }
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit)
{
Row = row;
Column = column;
Options = options;
_selectedOption = selectedOption;
_onChanged = onChanged;
CanEdit = canEdit;
}
partial void OnSelectedOptionChanged(StudentSeatOption value)
{
if (!_suppressChange) _onChanged(this);
}
public void SetSelectionSilently(StudentSeatOption option)
{
_suppressChange = true;
SelectedOption = option;
_suppressChange = false;
}
}
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 string _nameError = "";
[ObservableProperty] private string _layoutError = "";
public SeatingPlan? Result { get; private set; }
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;
if (editingPlan is null) return;
Name = editingPlan.Name;
Room = editingPlan.Room;
Rows = editingPlan.Rows;
Columns = editingPlan.Columns;
}
[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);
try
{
_plans.Save(plan);
Result = plan;
}
catch (InvalidOperationException ex)
{
NameError = ex.Message;
}
}
}
@@ -101,6 +101,11 @@
</DataGrid> </DataGrid>
</ContentPage> </ContentPage>
<!-- Tab: Sitzpläne -->
<ContentPage Header="Sitzpläne">
<views:SeatingPlanTabView DataContext="{Binding SeatingPlanTab}"/>
</ContentPage>
<!-- Tab: Mitarbeit --> <!-- Tab: Mitarbeit -->
<ContentPage Header="Mitarbeit"> <ContentPage Header="Mitarbeit">
<views:ParticipationTabView DataContext="{Binding ParticipationTab}"/> <views:ParticipationTabView DataContext="{Binding ParticipationTab}"/>
@@ -0,0 +1,55 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanDialog"
x:DataType="vm:SeatingPlanDialogViewModel"
Title="{Binding DialogTitle}" Width="480" Height="470"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Spacing="16">
<StackPanel Spacing="3">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<TextBlock Text="Name und Raum unterscheiden mehrere Pläne derselben Lerngruppe."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Name *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Name}" PlaceholderText="z. B. Standard, Gruppenarbeit"/>
<TextBlock Text="{Binding NameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding NameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Raum" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Room}" PlaceholderText="z. B. B204, Physikraum"/>
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Reihen" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding Rows}" Minimum="1" Maximum="10" Increment="1"
FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Plätze pro Reihe" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding Columns}" Minimum="1" Maximum="10" Increment="1"
FormatString="0"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding LayoutError}" Foreground="Red" FontSize="11"
IsVisible="{Binding LayoutError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
CornerRadius="6" Padding="12">
<TextBlock Text="Beim Verkleinern des Rasters entfallen Zuordnungen außerhalb der neuen Größe."
FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
</Border>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="{Binding SaveButtonText}" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class SeatingPlanDialog : Window
{
public SeatingPlanDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not SeatingPlanDialogViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,96 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanTabView"
x:DataType="vm:SeatingPlanTabViewModel">
<Grid ColumnDefinitions="260,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0" Padding="16">
<Grid RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,12">
<TextBlock Text="Sitzpläne" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="Mehrere Pläne, z. B. für verschiedene Räume" FontSize="12"
Opacity="0.6" TextWrapping="Wrap"/>
</StackPanel>
<ListBox Grid.Row="1" ItemsSource="{Binding Plans}" SelectedItem="{Binding SelectedPlan}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SeatingPlanSummary">
<StackPanel Margin="4,6" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RoomDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,0">
<Button Content=" Sitzplan" Command="{Binding AddPlanCommand}" HorizontalAlignment="Stretch"/>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding HasSelectedPlan}">
<Button Grid.Column="0" Content="Bearbeiten" Command="{Binding EditPlanCommand}"
HorizontalAlignment="Stretch"/>
<Button Grid.Column="2" Content="Löschen" Command="{Binding DeletePlanCommand}"
HorizontalAlignment="Stretch"/>
</Grid>
</StackPanel>
</Grid>
</Border>
<Grid Grid.Column="1">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10"
IsVisible="{Binding HasPlans, Converter={x:Static BoolConverters.Not}}">
<TextBlock Text="Noch kein Sitzplan" FontSize="20" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<TextBlock Text="Lege einen Plan an und ordne die Schüler den Plätzen zu."
Opacity="0.6" TextAlignment="Center"/>
<Button Content="Ersten Sitzplan anlegen" Command="{Binding AddPlanCommand}"
HorizontalAlignment="Center"/>
</StackPanel>
<Grid RowDefinitions="Auto,*" IsVisible="{Binding HasSelectedPlan}" Margin="24">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
<StackPanel Spacing="3">
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding AssignmentSummary}" VerticalAlignment="Bottom"
FontSize="12" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="14" HorizontalAlignment="Center">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
CornerRadius="6" Padding="36,8" HorizontalAlignment="Center">
<TextBlock Text="Tafel / Vorderseite" FontWeight="SemiBold" Opacity="0.75"/>
</Border>
<ItemsControl ItemsSource="{Binding Seats}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="{Binding PlanColumns}"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SeatCellViewModel">
<Border Width="190" MinHeight="76" Margin="5" Padding="10"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7">
<StackPanel Spacing="5">
<TextBlock Text="{Binding PositionLabel}" FontSize="10" Opacity="0.5"/>
<ComboBox ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption}"
IsEnabled="{Binding CanEdit}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:StudentSeatOption">
<TextBlock Text="{Binding DisplayName}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,46 @@
using Avalonia.Controls;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
namespace LehrerApp.Desktop.Views.Groups;
public partial class SeatingPlanTabView : UserControl
{
public SeatingPlanTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is SeatingPlanTabViewModel vm)
{
vm.OnEditPlan = ShowPlanDialog;
vm.OnConfirmDelete = ShowDeleteConfirmDialog;
}
}
private async Task<SeatingPlan?> ShowPlanDialog(SeatingPlan? plan)
{
if (DataContext is not SeatingPlanTabViewModel vm) return null;
var dialogVm = vm.CreateDialogViewModel(plan);
var dialog = new SeatingPlanDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || !await dialog.ShowDialog<bool>(owner)) return null;
return dialogVm.Result;
}
private async Task<bool> ShowDeleteConfirmDialog(SeatingPlanSummary plan)
{
var dialog = new ConfirmDialog
{
DataContext = new ConfirmDialogInfo
{
Title = "Sitzplan löschen?",
Message = $"Der Sitzplan „{plan.Name}“ und seine Zuordnungen werden gelöscht.",
ConfirmText = "Löschen",
},
};
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
}
+3
View File
@@ -1152,6 +1152,9 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
Inhaltssteuerelemente mit dokumentierten Tags wie `Letter.Salutation`, `Contact.Address`, Inhaltssteuerelemente mit dokumentierten Tags wie `Letter.Salutation`, `Contact.Address`,
`Student.FirstName` und `Group.Name`. `Student.FirstName` und `Group.Name`.
- [ ] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung. - [ ] **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.
### 7.2 Gruppen ### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/ - [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/