feat: add seating plans for learning groups
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`/
|
||||||
|
|||||||
Reference in New Issue
Block a user