Add animated classroom grouping with saved editable plans
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
This commit is contained in:
1 parent
d4b1419f9a
commit
f0462bd2a1
27 files changed
+775
-46
No files matched your search
@@ -9,6 +9,7 @@
|
|||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||||
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
||||||
|
<PackageVersion Include="Avalonia.Labs.Lottie" Version="12.0.2" />
|
||||||
|
|
||||||
<!-- MVVM & DI – .NET 10 kompatibel -->
|
<!-- MVVM & DI – .NET 10 kompatibel -->
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ public interface ISeatingPlanRepository
|
|||||||
/// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert.</summary>
|
/// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert.</summary>
|
||||||
void Restore(Guid trashId);
|
void Restore(Guid trashId);
|
||||||
}
|
}
|
||||||
|
public interface IStudentGroupPlanRepository
|
||||||
|
{
|
||||||
|
StudentGroupPlan? GetById(Guid id);
|
||||||
|
List<StudentGroupPlan> GetByGroup(Guid groupId);
|
||||||
|
List<StudentGroupPlan> GetByStudent(Guid studentId);
|
||||||
|
void Save(StudentGroupPlan plan);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
public interface IGroupMembershipRepository
|
public interface IGroupMembershipRepository
|
||||||
{
|
{
|
||||||
List<GroupMembership> GetByStudent(Guid studentId);
|
List<GroupMembership> GetByStudent(Guid studentId);
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ public sealed record StudentReferenceSummary(
|
|||||||
int Grades,
|
int Grades,
|
||||||
int ReportGrades,
|
int ReportGrades,
|
||||||
int ParticipationEntries,
|
int ParticipationEntries,
|
||||||
int DocumentationEntries)
|
int DocumentationEntries,
|
||||||
|
int StudentGroupPlans = 0)
|
||||||
{
|
{
|
||||||
public int Total => Memberships + ExamResults + Grades + ReportGrades
|
public int Total => Memberships + ExamResults + Grades + ReportGrades
|
||||||
+ ParticipationEntries + DocumentationEntries;
|
+ ParticipationEntries + DocumentationEntries + StudentGroupPlans;
|
||||||
public bool HasReferences => Total > 0;
|
public bool HasReferences => Total > 0;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>Eine wiederverwendbare Einteilung innerhalb einer Lerngruppe.</summary>
|
||||||
|
public class StudentGroupPlan
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public Guid GroupId { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public List<StudentGroupPlanGroup> Groups { get; set; } = [];
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StudentGroupPlanGroup
|
||||||
|
{
|
||||||
|
public List<Guid> StudentIds { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ public class PersonalDataExportService(
|
|||||||
IExamResultRepository examResults,
|
IExamResultRepository examResults,
|
||||||
IParticipationRepository participation,
|
IParticipationRepository participation,
|
||||||
IDocumentationRepository documentation,
|
IDocumentationRepository documentation,
|
||||||
ISeatingPlanRepository seatingPlans)
|
ISeatingPlanRepository seatingPlans,
|
||||||
|
IStudentGroupPlanRepository? studentGroupPlans = null)
|
||||||
{
|
{
|
||||||
public string ExportAsJson(Guid studentId)
|
public string ExportAsJson(Guid studentId)
|
||||||
{
|
{
|
||||||
@@ -56,6 +57,16 @@ public class PersonalDataExportService(
|
|||||||
Platz = a.Column + 1,
|
Platz = a.Column + 1,
|
||||||
}))
|
}))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
GespeicherteGruppeneinteilungen = (studentGroupPlans?.GetByStudent(studentId) ?? [])
|
||||||
|
.SelectMany(plan => plan.Groups.Select((subgroup, index) => new
|
||||||
|
{
|
||||||
|
Einteilung = plan.Name,
|
||||||
|
Gruppe = index + 1,
|
||||||
|
Enthalten = subgroup.StudentIds.Contains(studentId),
|
||||||
|
}))
|
||||||
|
.Where(entry => entry.Enthalten)
|
||||||
|
.Select(entry => new { entry.Einteilung, entry.Gruppe })
|
||||||
|
.ToList(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return JsonSerializer.Serialize(dto, new JsonSerializerOptions
|
return JsonSerializer.Serialize(dto, new JsonSerializerOptions
|
||||||
|
|||||||
@@ -8,6 +8,72 @@ public sealed class RepositoryTests
|
|||||||
{
|
{
|
||||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentGroupPlanRepository_SpeichertEinteilungUndLoeschtSieMitDerLerngruppe()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var group = new LearningGroup { Name = "8a" };
|
||||||
|
new GroupRepository(db).Save(group);
|
||||||
|
var studentIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||||
|
var plan = new StudentGroupPlan
|
||||||
|
{
|
||||||
|
GroupId = group.Id,
|
||||||
|
Name = " Projektwoche ",
|
||||||
|
Groups =
|
||||||
|
[
|
||||||
|
new StudentGroupPlanGroup { StudentIds = [studentIds[0], studentIds[1]] },
|
||||||
|
new StudentGroupPlanGroup { StudentIds = [studentIds[2]] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var changes = new List<(string Type, string Operation)>();
|
||||||
|
db.OnChange = (type, _, operation, _) => changes.Add((type, operation));
|
||||||
|
var repo = new StudentGroupPlanRepository(db);
|
||||||
|
|
||||||
|
repo.Save(plan);
|
||||||
|
Assert.Equal("Projektwoche", repo.GetById(plan.Id)?.Name);
|
||||||
|
Assert.Equal(studentIds[2], repo.GetByGroup(group.Id).Single().Groups[1].StudentIds.Single());
|
||||||
|
Assert.Equal(plan.Id, Assert.Single(repo.GetByStudent(studentIds[0])).Id);
|
||||||
|
Assert.Contains((nameof(StudentGroupPlan), "Save"), changes);
|
||||||
|
|
||||||
|
var student = new Student { Id = studentIds[0], FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var students = new StudentRepository(db);
|
||||||
|
students.Save(student);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => students.Delete(student.Id));
|
||||||
|
|
||||||
|
new GroupRepository(db).Delete(group.Id);
|
||||||
|
Assert.Empty(repo.GetByGroup(group.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StudentGroupPlanRepository_VerhindertDoppelteSchuelerUndNamen()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var group = new LearningGroup { Name = "8a" };
|
||||||
|
new GroupRepository(db).Save(group);
|
||||||
|
var repo = new StudentGroupPlanRepository(db);
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
Assert.Throws<InvalidOperationException>(() => repo.Save(new StudentGroupPlan
|
||||||
|
{
|
||||||
|
GroupId = group.Id, Name = "Doppelt",
|
||||||
|
Groups =
|
||||||
|
[
|
||||||
|
new StudentGroupPlanGroup { StudentIds = [studentId] },
|
||||||
|
new StudentGroupPlanGroup { StudentIds = [studentId] },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
repo.Save(new StudentGroupPlan
|
||||||
|
{
|
||||||
|
GroupId = group.Id, Name = "Projektwoche",
|
||||||
|
Groups = [new StudentGroupPlanGroup { StudentIds = [studentId] }],
|
||||||
|
});
|
||||||
|
Assert.Throws<InvalidOperationException>(() => repo.Save(new StudentGroupPlan
|
||||||
|
{
|
||||||
|
GroupId = group.Id, Name = "projektwoche",
|
||||||
|
Groups = [new StudentGroupPlanGroup()],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ── StudentRepository ─────────────────────────────────────────────────────
|
// ── StudentRepository ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -41,6 +41,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<SeatingPlan> SeatingPlans => _db.GetCollection<SeatingPlan>("seating_plans");
|
||||||
|
public ILiteCollection<StudentGroupPlan> StudentGroupPlans => _db.GetCollection<StudentGroupPlan>("student_group_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");
|
||||||
@@ -141,6 +142,8 @@ public class LiteDbContext : IDisposable
|
|||||||
|
|
||||||
foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList())
|
foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList())
|
||||||
SeatingPlans.Delete(plan.Id);
|
SeatingPlans.Delete(plan.Id);
|
||||||
|
foreach (var plan in StudentGroupPlans.Find(p => p.GroupId == id).ToList())
|
||||||
|
StudentGroupPlans.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.
|
||||||
@@ -659,6 +662,7 @@ public class LiteDbContext : IDisposable
|
|||||||
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);
|
SeatingPlans.EnsureIndex(x => x.GroupId);
|
||||||
|
StudentGroupPlans.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",
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
db.Grades.Count(g => g.StudentId == studentId),
|
db.Grades.Count(g => g.StudentId == studentId),
|
||||||
db.ReportGrades.Count(g => g.StudentId == studentId),
|
db.ReportGrades.Count(g => g.StudentId == studentId),
|
||||||
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
||||||
db.Documentation.FindAll().Count(d => d.Concerns(studentId)));
|
db.Documentation.FindAll().Count(d => d.Concerns(studentId)),
|
||||||
|
db.StudentGroupPlans.FindAll().Count(plan =>
|
||||||
|
plan.Groups.Any(group => group.StudentIds.Contains(studentId))));
|
||||||
public void Save(Student s)
|
public void Save(Student s)
|
||||||
{
|
{
|
||||||
s.ExternalIds ??= [];
|
s.ExternalIds ??= [];
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Data.Repositories;
|
||||||
|
|
||||||
|
public class StudentGroupPlanRepository(LiteDbContext db) : IStudentGroupPlanRepository
|
||||||
|
{
|
||||||
|
public StudentGroupPlan? GetById(Guid id) => db.StudentGroupPlans.FindById(id);
|
||||||
|
|
||||||
|
public List<StudentGroupPlan> GetByGroup(Guid groupId) =>
|
||||||
|
db.StudentGroupPlans.Find(p => p.GroupId == groupId)
|
||||||
|
.OrderByDescending(p => p.UpdatedAt).ToList();
|
||||||
|
|
||||||
|
public List<StudentGroupPlan> GetByStudent(Guid studentId) =>
|
||||||
|
db.StudentGroupPlans.FindAll()
|
||||||
|
.Where(plan => plan.Groups.Any(group => group.StudentIds.Contains(studentId)))
|
||||||
|
.OrderByDescending(plan => plan.UpdatedAt).ToList();
|
||||||
|
|
||||||
|
public void Save(StudentGroupPlan plan)
|
||||||
|
{
|
||||||
|
if (db.Groups.FindById(plan.GroupId) is null)
|
||||||
|
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
|
||||||
|
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
||||||
|
plan.Name = plan.Name?.Trim() ?? "";
|
||||||
|
if (plan.Name.Length == 0)
|
||||||
|
throw new ArgumentException("Bitte einen Namen für die Gruppeneinteilung eingeben.");
|
||||||
|
plan.Groups ??= [];
|
||||||
|
if (plan.Groups.Count == 0)
|
||||||
|
throw new InvalidOperationException("Die Gruppeneinteilung enthält keine Gruppen.");
|
||||||
|
var ids = plan.Groups.SelectMany(g => g.StudentIds ?? []).ToList();
|
||||||
|
if (ids.Count != ids.Distinct().Count())
|
||||||
|
throw new InvalidOperationException("Ein Schüler darf nur in einer Gruppe stehen.");
|
||||||
|
if (db.StudentGroupPlans.Find(p => p.GroupId == plan.GroupId)
|
||||||
|
.Any(p => p.Id != plan.Id && string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new InvalidOperationException("Eine gespeicherte Einteilung mit diesem Namen gibt es bereits.");
|
||||||
|
|
||||||
|
if (db.StudentGroupPlans.FindById(plan.Id) is { } existing)
|
||||||
|
{
|
||||||
|
if (existing.GroupId != plan.GroupId)
|
||||||
|
throw new InvalidOperationException("Eine Gruppeneinteilung kann nicht in eine andere Lerngruppe verschoben werden.");
|
||||||
|
plan.CreatedAt = existing.CreatedAt;
|
||||||
|
}
|
||||||
|
plan.UpdatedAt = DateTime.UtcNow;
|
||||||
|
db.StudentGroupPlans.Upsert(plan);
|
||||||
|
db.OnChange?.Invoke(nameof(StudentGroupPlan), plan.Id.ToString(), "Save", plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(Guid id)
|
||||||
|
{
|
||||||
|
if (db.StudentGroupPlans.FindById(id) is not { } plan) return;
|
||||||
|
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
|
||||||
|
db.StudentGroupPlans.Delete(id);
|
||||||
|
db.OnChange?.Invoke(nameof(StudentGroupPlan), id.ToString(), "Delete", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,6 +179,22 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
|
|||||||
public void Restore(Guid trashId) { }
|
public void Restore(Guid trashId) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class FakeStudentGroupPlans : IStudentGroupPlanRepository
|
||||||
|
{
|
||||||
|
private readonly List<StudentGroupPlan> _plans = [];
|
||||||
|
public StudentGroupPlan? GetById(Guid id) => _plans.FirstOrDefault(p => p.Id == id);
|
||||||
|
public List<StudentGroupPlan> GetByGroup(Guid groupId) =>
|
||||||
|
_plans.Where(p => p.GroupId == groupId).ToList();
|
||||||
|
public List<StudentGroupPlan> GetByStudent(Guid studentId) =>
|
||||||
|
_plans.Where(p => p.Groups.Any(g => g.StudentIds.Contains(studentId))).ToList();
|
||||||
|
public void Save(StudentGroupPlan plan)
|
||||||
|
{
|
||||||
|
_plans.RemoveAll(p => p.Id == plan.Id);
|
||||||
|
_plans.Add(plan);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id) => _plans.RemoveAll(p => p.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
public class FakeTrash(List<TrashedItem>? initial = null) : ITrashRepository
|
public class FakeTrash(List<TrashedItem>? initial = null) : ITrashRepository
|
||||||
{
|
{
|
||||||
private readonly List<TrashedItem> _all = initial ?? [];
|
private readonly List<TrashedItem> _all = initial ?? [];
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ public sealed class PersonalDataExportServiceTests
|
|||||||
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = studentId }],
|
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = studentId }],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
var groupPlans = new FakeStudentGroupPlans();
|
||||||
|
groupPlans.Save(new StudentGroupPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Projektwoche",
|
||||||
|
Groups = [new StudentGroupPlanGroup { StudentIds = [studentId] }],
|
||||||
|
});
|
||||||
var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries,
|
var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries,
|
||||||
documentation, seatingPlans);
|
documentation, seatingPlans, groupPlans);
|
||||||
|
|
||||||
var json = service.ExportAsJson(studentId);
|
var json = service.ExportAsJson(studentId);
|
||||||
|
|
||||||
@@ -41,6 +47,7 @@ public sealed class PersonalDataExportServiceTests
|
|||||||
Assert.Contains("\"Value\": \"2\"", json);
|
Assert.Contains("\"Value\": \"2\"", json);
|
||||||
Assert.Contains("\"Sitzplan\": \"Standard\"", json);
|
Assert.Contains("\"Sitzplan\": \"Standard\"", json);
|
||||||
Assert.Contains("\"Reihe\": 2", json);
|
Assert.Contains("\"Reihe\": 2", json);
|
||||||
|
Assert.Contains("Projektwoche", json);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -53,4 +60,23 @@ public sealed class PersonalDataExportServiceTests
|
|||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(() => service.ExportAsJson(Guid.NewGuid()));
|
Assert.Throws<InvalidOperationException>(() => service.ExportAsJson(Guid.NewGuid()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExportAsJson_EnthaeltGespeichertenPlanAuchNachEndeDerMitgliedschaft()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var plans = new FakeStudentGroupPlans();
|
||||||
|
plans.Save(new StudentGroupPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Alte Projektgruppe",
|
||||||
|
Groups = [new StudentGroupPlanGroup { StudentIds = [student.Id] }],
|
||||||
|
});
|
||||||
|
var service = new PersonalDataExportService(
|
||||||
|
new FakeStudents([student]), new FakeGroups([]), new FakeMemberships([]),
|
||||||
|
new FakeGrades(), new FakeResults(), new FakeEntries(), new FakeDocumentation(),
|
||||||
|
new FakeSeatingPlans(), plans);
|
||||||
|
|
||||||
|
Assert.Contains("Alte Projektgruppe", service.ExportAsJson(student.Id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,8 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
public sealed class StudentGroupGeneratorViewModelTests
|
public sealed class StudentGroupGeneratorViewModelTests
|
||||||
{
|
{
|
||||||
private static (StudentGroupGeneratorViewModel Vm, List<Student> Students, LearningGroup Group,
|
private static (StudentGroupGeneratorViewModel Vm, List<Student> Students, LearningGroup Group,
|
||||||
FakeSessions Sessions, FakeEntries Entries) Build(int studentCount = 10)
|
FakeSessions Sessions, FakeEntries Entries) Build(int studentCount = 10,
|
||||||
|
FakeStudentGroupPlans? savedPlans = null)
|
||||||
{
|
{
|
||||||
var group = new LearningGroup { Name = "8a", GradingSystem = GradingSystem.Grades1To6 };
|
var group = new LearningGroup { Name = "8a", GradingSystem = GradingSystem.Grades1To6 };
|
||||||
var students = Enumerable.Range(1, studentCount).Select(i => new Student
|
var students = Enumerable.Range(1, studentCount).Select(i => new Student
|
||||||
@@ -22,10 +23,77 @@ public sealed class StudentGroupGeneratorViewModelTests
|
|||||||
var entries = new FakeEntries();
|
var entries = new FakeEntries();
|
||||||
var vm = new StudentGroupGeneratorViewModel(
|
var vm = new StudentGroupGeneratorViewModel(
|
||||||
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
|
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
|
||||||
sessions, entries, new FakeGrades(), group.Id, new Random(7));
|
sessions, entries, new FakeGrades(), group.Id, new Random(7), savedPlans);
|
||||||
return (vm, students, group, sessions, entries);
|
return (vm, students, group, sessions, entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManuelleUmverteilung_WirdGespeichertUndSpaeterWiederGeladen()
|
||||||
|
{
|
||||||
|
var plans = new FakeStudentGroupPlans();
|
||||||
|
var (vm, students, group, _, _) = Build(6, plans);
|
||||||
|
vm.TargetGroupCount = 2;
|
||||||
|
vm.CountPriority = 100;
|
||||||
|
vm.GenerateCommand.Execute(null);
|
||||||
|
var source = vm.GeneratedGroups[0];
|
||||||
|
var target = vm.GeneratedGroups[1];
|
||||||
|
var member = source.Members[0];
|
||||||
|
|
||||||
|
Assert.True(vm.MoveStudent(member, target));
|
||||||
|
Assert.False(vm.MoveStudent(member, target));
|
||||||
|
Assert.DoesNotContain(member, source.Members);
|
||||||
|
vm.PlanName = "Projektwoche";
|
||||||
|
vm.SavePlanCommand.Execute(null);
|
||||||
|
|
||||||
|
var saved = Assert.Single(plans.GetByGroup(group.Id));
|
||||||
|
Assert.Equal("Projektwoche", saved.Name);
|
||||||
|
Assert.Contains(member.Id, saved.Groups[1].StudentIds);
|
||||||
|
Assert.False(vm.HasUnsavedChanges);
|
||||||
|
|
||||||
|
vm.GenerateCommand.Execute(null);
|
||||||
|
vm.SelectedSavedPlan = Assert.Single(vm.SavedPlans);
|
||||||
|
vm.LoadSavedPlanCommand.Execute(null);
|
||||||
|
Assert.Contains(vm.GeneratedGroups[1].Members, m => m.Id == member.Id);
|
||||||
|
Assert.DoesNotContain(vm.GeneratedGroups[0].Members, m => m.Id == member.Id);
|
||||||
|
Assert.False(vm.HasUnsavedChanges);
|
||||||
|
Assert.Equal("Projektwoche", vm.PlanName);
|
||||||
|
|
||||||
|
var reopened = new StudentGroupGeneratorViewModel(
|
||||||
|
new FakeStudents(students), new FakeGroups([group]),
|
||||||
|
new FakeMemberships(students.Select(s => new GroupMembership
|
||||||
|
{
|
||||||
|
GroupId = group.Id, StudentId = s.Id,
|
||||||
|
}).ToList()),
|
||||||
|
new FakeSessions([]), new FakeEntries(), new FakeGrades(), group.Id,
|
||||||
|
savedPlans: plans);
|
||||||
|
reopened.SelectedSavedPlan = Assert.Single(reopened.SavedPlans);
|
||||||
|
reopened.LoadSavedPlanCommand.Execute(null);
|
||||||
|
Assert.Contains(reopened.GeneratedGroups[1].Members, m => m.Id == member.Id);
|
||||||
|
|
||||||
|
reopened.PlanName = "Projektwoche aktualisiert";
|
||||||
|
reopened.SavePlanCommand.Execute(null);
|
||||||
|
Assert.Equal("Projektwoche aktualisiert", Assert.Single(plans.GetByGroup(group.Id)).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpeichernUeberschreibtAusgewaehltenPlanErstNachDemLaden()
|
||||||
|
{
|
||||||
|
var plans = new FakeStudentGroupPlans();
|
||||||
|
var (vm, _, group, _, _) = Build(6, plans);
|
||||||
|
vm.GenerateCommand.Execute(null);
|
||||||
|
vm.PlanName = "Alter Plan";
|
||||||
|
vm.SavePlanCommand.Execute(null);
|
||||||
|
|
||||||
|
vm.GenerateCommand.Execute(null);
|
||||||
|
vm.SelectedSavedPlan = Assert.Single(vm.SavedPlans);
|
||||||
|
vm.PlanName = "Neuer Plan";
|
||||||
|
vm.SavePlanCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(2, plans.GetByGroup(group.Id).Count);
|
||||||
|
Assert.Contains(plans.GetByGroup(group.Id), p => p.Name == "Alter Plan");
|
||||||
|
Assert.Contains(plans.GetByGroup(group.Id), p => p.Name == "Neuer Plan");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FesteZielgroesse_ErzeugtPassendeAnzahlMitKleinerAusnahme()
|
public void FesteZielgroesse_ErzeugtPassendeAnzahlMitKleinerAusnahme()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<IStudentRepository, StudentRepository>();
|
services.AddSingleton<IStudentRepository, StudentRepository>();
|
||||||
services.AddSingleton<IGroupRepository, GroupRepository>();
|
services.AddSingleton<IGroupRepository, GroupRepository>();
|
||||||
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
|
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
|
||||||
|
services.AddSingleton<IStudentGroupPlanRepository, StudentGroupPlanRepository>();
|
||||||
services.AddSingleton<ITrashRepository, TrashRepository>();
|
services.AddSingleton<ITrashRepository, TrashRepository>();
|
||||||
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
|
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
|
||||||
services.AddSingleton<IExamRepository, ExamRepository>();
|
services.AddSingleton<IExamRepository, ExamRepository>();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":"5.7.8","fr":25,"ip":0,"op":25,"w":1080,"h":1080,"nm":"[ pecah Asset - Games Roda Keberuntungan ] EMOS - Juni","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"link-tengah Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[538.46,407.962,0],"ix":2,"l":2},"a":{"a":0,"k":[65.18,65.182,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.586,31.425],[-31.425,-1.586],[1.586,-31.426],[31.425,1.588]],"o":[[1.588,-31.423],[31.426,1.588],[-1.589,31.425],[-31.425,-1.586]],"v":[[-56.9,-2.875],[2.873,-56.901],[56.9,2.873],[-2.874,56.899]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-1.668,33.037],[33.036,1.668],[1.668,-33.037],[-33.036,-1.671]],"o":[[1.67,-33.037],[-33.037,-1.668],[-1.67,33.036],[33.037,1.668]],"v":[[59.817,3.02],[3.021,-59.818],[-59.817,-3.022],[-3.022,59.818]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.2196078431372549,0.28627450980392155,0.8196078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[65.181,65.182],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-28.146,-8.66],[8.66,-28.146],[28.147,8.66],[-7.333,25.672]],"o":[[28.146,8.661],[-8.66,28.144],[-28.146,-8.661],[8.09,-28.315]],"v":[[14.668,-49.317],[50.116,15.469],[-14.669,49.694],[-50.116,-13.869]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-30.651,-9.431],[-9.431,30.652],[30.651,9.432],[9.431,-30.652]],"o":[[30.652,9.432],[9.432,-30.652],[-30.652,-9.431],[-9.431,30.651]],"v":[[-17.078,55.5],[55.499,17.076],[17.077,-55.501],[-55.5,-17.077]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[65.181,65.182],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":6,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.55,30.701],[30.701,1.55],[1.55,-30.701],[-30.702,-1.552]],"o":[[1.552,-30.701],[-30.701,-1.55],[-1.552,30.701],[30.701,1.55]],"v":[[55.589,2.806],[2.808,-55.59],[-55.588,-2.809],[-2.808,55.59]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3686274509803922,0.4470588235294118,0.8941176470588236,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[65.18,65.182],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":25,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Arrow Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[-17]},{"t":7,"s":[10]}],"ix":10,"x":"var $bm_rt;\n$bm_rt = loopOut('cycle', 0);"},"p":{"a":0,"k":[882.284,387.765,0],"ix":2,"l":2},"a":{"a":0,"k":[90.2,66.694,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.184,-20.641],[-20.642,-1.186],[-1.183,20.641],[20.643,1.183]],"o":[[-1.185,20.643],[20.643,1.184],[1.186,-20.644],[-20.643,-1.185]],"v":[[-37.376,-2.145],[-2.144,37.377],[37.375,2.146],[2.145,-37.375]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.2196078431372549,0.28627450980392155,0.8196078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[112.538,63.94],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k"Line truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":"5.6.6","fr":60,"ip":60,"op":120,"w":600,"h":600,"nm":"development and submittals","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"person-top-face-outlinne Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.415,-16.001,0],"ix":2},"a":{"a":0,"k":[33.313,42.52,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.515,0],[2.745,1.593],[0,10.18],[-5.843,10.003],[-8.233,4.781],[-6.463,-3.719],[0,-10.181],[5.754,-10.093],[8.234,-4.692]],"o":[[-3.276,0],[-6.551,-3.718],[0,-9.561],[5.754,-10.004],[8.853,-5.046],[6.551,3.718],[0,9.561],[-5.843,10.092],[-5.223,2.921]],"v":[[-13.102,41.52],[-22.221,39.13],[-32.313,17.529],[-23.283,-12.836],[-1.505,-35.765],[22.22,-37.801],[32.313,-16.2],[23.372,14.165],[1.593,37.094]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[2.214,0],[4.161,-2.301],[5.312,-9.118],[0,-8.41],[-4.516,-2.656],[-6.816,3.895],[-0.089,0],[-5.222,9.119],[0,8.498],[4.603,2.656]],"o":[[-3.452,0],[-7.348,4.25],[-5.312,9.118],[0,7.879],[4.514,2.656],[0.089,0],[7.17,-4.16],[5.224,-9.118],[0,-7.88],[-1.682,-0.973]],"v":[[13.013,-33.995],[1.505,-30.454],[-18.06,-9.738],[-26.205,17.529],[-19.21,33.818],[-1.594,31.782],[-1.416,31.693],[17.882,11.066],[26.027,-16.2],[18.945,-32.49]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0.4,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[33.313,42.52],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"person-top-face-face Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.324,-15.34,0],"ix":2},"a":{"a":0,"k":[29.464,39.069,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-8.056,4.692],[-5.223,9.119],[0,9.295],[5.312,3.099],[8.056,-4.692],[5.223,-9.207],[0,-9.384],[-5.312,-3.098]],"o":[[8.056,-4.692],[5.312,-9.118],[0,-9.296],[-5.312,-3.098],[-8.056,4.692],[-5.312,9.119],[0,9.296],[5.312,2.833]],"v":[[0,33.773],[20.627,11.995],[29.214,-16.775],[20.627,-35.722],[0,-33.684],[-20.627,-11.818],[-29.214,17.041],[-20.627,35.986]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[29.464,39.07],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"person-top-body-front-outline Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.461,58.935,0],"ix":2},"a":{"a":0,"k":[53.718,49.646,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.531,0],[0.443,0.266],[0,1.151],[-8.764,15.05],[-13.633,7.967],[-10.358,-5.932],[-0.886,-14.431],[0.974,-0.531],[0,0]],"o":[[-0.62,0],[-0.973,-0.531],[0.886,-14.784],[9.649,-16.732],[14.253,-8.233],[9.471,5.488],[0.088,1.151],[0,0],[-0.62,0.354]],"v":[[-49.619,48.646],[-51.214,48.204],[-52.719,45.371],[-37.846,-0.929],[-1.638,-39.173],[36.607,-42.714],[52.63,-11.906],[51.125,-9.074],[-48.025,48.204]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[4.249,0],[7.437,-4.249],[9.119,-15.758],[1.682,-12.305],[0,0],[7.171,4.161]],"o":[[-6.286,0],[-12.837,7.436],[-7.082,12.306],[0,0],[-1.063,-11.332],[-3.364,-1.948]],"v":[[22.089,-40.147],[1.372,-33.773],[-32.622,2.169],[-46.079,39.971],[46.257,-13.324],[33.508,-37.314]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0.4,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1",Line truncated
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||||
|
<PackageReference Include="Avalonia.Labs.Lottie" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="QuestPDF" />
|
<PackageReference Include="QuestPDF" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using System.Collections.ObjectModel;
|
|||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
public sealed record StudentGroupMember(string Name);
|
public sealed record StudentGroupMember(Guid Id, string Name);
|
||||||
public sealed record StudentPairCandidate(Guid Id, string Name);
|
public sealed record StudentPairCandidate(Guid Id, string Name);
|
||||||
|
|
||||||
public sealed class StudentPairRule
|
public sealed class StudentPairRule
|
||||||
@@ -29,8 +29,9 @@ public sealed class StudentPairRule
|
|||||||
FirstId == first && SecondId == second || FirstId == second && SecondId == first;
|
FirstId == first && SecondId == second || FirstId == second && SecondId == first;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class GeneratedStudentGroup
|
public sealed partial class GeneratedStudentGroup : ObservableObject
|
||||||
{
|
{
|
||||||
|
[ObservableProperty] private bool _isDropTarget;
|
||||||
public string Title { get; }
|
public string Title { get; }
|
||||||
public ObservableCollection<StudentGroupMember> Members { get; } = [];
|
public ObservableCollection<StudentGroupMember> Members { get; } = [];
|
||||||
|
|
||||||
@@ -39,7 +40,13 @@ public sealed class GeneratedStudentGroup
|
|||||||
Title = $"Gruppe {number}";
|
Title = $"Gruppe {number}";
|
||||||
foreach (var student in students.OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase)
|
foreach (var student in students.OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase)
|
||||||
.ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase))
|
.ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase))
|
||||||
Members.Add(new StudentGroupMember(student.FullName));
|
Members.Add(new StudentGroupMember(student.Id, student.FullName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public GeneratedStudentGroup(int number, IEnumerable<StudentGroupMember> members)
|
||||||
|
{
|
||||||
|
Title = $"Gruppe {number}";
|
||||||
|
foreach (var member in members) Members.Add(member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +62,12 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
|
|||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
private readonly IGradeRepository _grades;
|
private readonly IGradeRepository _grades;
|
||||||
private readonly IGroupRepository _groupRepository;
|
private readonly IGroupRepository _groupRepository;
|
||||||
|
private readonly IStudentGroupPlanRepository? _savedPlans;
|
||||||
private readonly Random _random;
|
private readonly Random _random;
|
||||||
private readonly List<Student> _participants = [];
|
private readonly List<Student> _participants = [];
|
||||||
private readonly Dictionary<Guid, double?> _performance = [];
|
private readonly Dictionary<Guid, double?> _performance = [];
|
||||||
private readonly HashSet<Guid> _supportStudentIds = [];
|
private readonly HashSet<Guid> _supportStudentIds = [];
|
||||||
|
private Guid? _editingPlanId;
|
||||||
private string _attendanceWarning = "";
|
private string _attendanceWarning = "";
|
||||||
|
|
||||||
[ObservableProperty] private StudentDrawGroup? _selectedGroup;
|
[ObservableProperty] private StudentDrawGroup? _selectedGroup;
|
||||||
@@ -77,14 +86,24 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _statusText = "Optionen wählen und Gruppen zusammenstellen.";
|
[ObservableProperty] private string _statusText = "Optionen wählen und Gruppen zusammenstellen.";
|
||||||
[ObservableProperty] private string _warningText = "";
|
[ObservableProperty] private string _warningText = "";
|
||||||
[ObservableProperty] private bool _hasGeneratedGroups;
|
[ObservableProperty] private bool _hasGeneratedGroups;
|
||||||
|
[ObservableProperty] private StudentGroupPlan? _selectedSavedPlan;
|
||||||
|
[ObservableProperty] private string _planName = "";
|
||||||
|
[ObservableProperty] private string _saveStatus = "";
|
||||||
|
[ObservableProperty] private bool _hasUnsavedChanges;
|
||||||
|
|
||||||
public ObservableCollection<StudentDrawGroup> Groups { get; } = [];
|
public ObservableCollection<StudentDrawGroup> Groups { get; } = [];
|
||||||
public ObservableCollection<GeneratedStudentGroup> GeneratedGroups { get; } = [];
|
public ObservableCollection<GeneratedStudentGroup> GeneratedGroups { get; } = [];
|
||||||
public ObservableCollection<StudentPairCandidate> PairCandidates { get; } = [];
|
public ObservableCollection<StudentPairCandidate> PairCandidates { get; } = [];
|
||||||
public ObservableCollection<StudentPairRule> ForcedPairs { get; } = [];
|
public ObservableCollection<StudentPairRule> ForcedPairs { get; } = [];
|
||||||
public ObservableCollection<StudentPairRule> ExcludedPairs { get; } = [];
|
public ObservableCollection<StudentPairRule> ExcludedPairs { get; } = [];
|
||||||
|
public ObservableCollection<StudentGroupPlan> SavedPlans { get; } = [];
|
||||||
public string[] ParticipantModeOptions { get; } = [FutureModeLabel, AdHocModeLabel];
|
public string[] ParticipantModeOptions { get; } = [FutureModeLabel, AdHocModeLabel];
|
||||||
public bool CanGenerate => SelectedGroup is not null && _participants.Count >= 2;
|
public bool CanGenerate => SelectedGroup is not null && _participants.Count >= 2;
|
||||||
|
public bool CanSave => _savedPlans is not null && SelectedGroup is not null &&
|
||||||
|
HasGeneratedGroups && !string.IsNullOrWhiteSpace(PlanName);
|
||||||
|
public bool CanLoadSavedPlan => SelectedSavedPlan is not null;
|
||||||
|
public bool CanDeleteSavedPlan => SelectedSavedPlan is not null;
|
||||||
|
public bool HasSavedPlans => SavedPlans.Count > 0;
|
||||||
public string SizePriorityLabel => PriorityLabel(SizePriority);
|
public string SizePriorityLabel => PriorityLabel(SizePriority);
|
||||||
public string CountPriorityLabel => PriorityLabel(CountPriority);
|
public string CountPriorityLabel => PriorityLabel(CountPriority);
|
||||||
public string PerformanceDistributionLabel => PerformanceDistribution switch
|
public string PerformanceDistributionLabel => PerformanceDistribution switch
|
||||||
@@ -106,7 +125,8 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
|
|||||||
IParticipationRepository participation,
|
IParticipationRepository participation,
|
||||||
IGradeRepository grades,
|
IGradeRepository grades,
|
||||||
Guid? initialGroupId = null,
|
Guid? initialGroupId = null,
|
||||||
Random? random = null)
|
Random? random = null,
|
||||||
|
IStudentGroupPlanRepository? savedPlans = null)
|
||||||
{
|
{
|
||||||
_students = students;
|
_students = students;
|
||||||
_groupRepository = groups;
|
_groupRepository = groups;
|
||||||
@@ -115,9 +135,13 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
|
|||||||
_participation = participation;
|
_participation = participation;
|
||||||
_grades = grades;
|
_grades = grades;
|
||||||
_random = random ?? Random.Shared;
|
_random = random ?? Random.Shared;
|
||||||
|
_savedPlans = savedPlans;
|
||||||
|
|
||||||
foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
|
foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||||
Groups.Add(new StudentDrawGroup(group.Id, group.Name));
|
Groups.Add(new StudentDrawGroup(group.Id, group.Name));
|
||||||
|
if (initialGroupId is { } requestedId && Groups.All(g => g.Id != requestedId) &&
|
||||||
|
groups.GetById(requestedId) is { } archived)
|
||||||
|
Groups.Add(new StudentDrawGroup(archived.Id, $"{archived.Name} (archiviert)"));
|
||||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault();
|
SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +150,24 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
|
|||||||
ForcedPairs.Clear();
|
ForcedPairs.Clear();
|
||||||
ExcludedPairs.Clear();
|
ExcludedPairs.Clear();
|
||||||
ReloadParticipants();
|
ReloadParticipants();
|
||||||
|
ReloadSavedPlans();
|
||||||
|
_editingPlanId = null;
|
||||||
|
PlanName = "";
|
||||||
}
|
}
|
||||||
partial void OnParticipantModeNameChanged(string value) => ReloadParticipants();
|
partial void OnParticipantModeNameChanged(string value) => ReloadParticipants();
|
||||||
|
partial void OnSelectedSavedPlanChanged(StudentGroupPlan? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CanLoadSavedPlan));
|
||||||
|
OnPropertyChanged(nameof(CanDeleteSavedPlan));
|
||||||
|
LoadSavedPlanCommand.NotifyCanExecuteChanged();
|
||||||
|
DeleteSavedPlanCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
partial void OnPlanNameChanged(string value)
|
||||||
|
{
|
||||||
|
SavePlanCommand.NotifyCanExecuteChanged();
|
||||||
|
if (HasGeneratedGroups) HasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
partial void OnHasGeneratedGroupsChanged(bool value) => SavePlanCommand.NotifyCanExecuteChanged();
|
||||||
partial void OnTargetGroupSizeChanged(int value) => InvalidateResult();
|
partial void OnTargetGroupSizeChanged(int value) => InvalidateResult();
|
||||||
partial void OnTargetGroupCountChanged(int value) => InvalidateResult();
|
partial void OnTargetGroupCountChanged(int value) => InvalidateResult();
|
||||||
partial void OnSizePriorityChanged(double value)
|
partial void OnSizePriorityChanged(double value)
|
||||||
@@ -177,11 +217,118 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
|
|||||||
GeneratedGroups.Add(new GeneratedStudentGroup(i + 1, assignments[i]));
|
GeneratedGroups.Add(new GeneratedStudentGroup(i + 1, assignments[i]));
|
||||||
|
|
||||||
HasGeneratedGroups = true;
|
HasGeneratedGroups = true;
|
||||||
|
_editingPlanId = null;
|
||||||
|
SelectedSavedPlan = null;
|
||||||
|
PlanName = $"Einteilung {DateTime.Now:dd.MM.yyyy HH:mm:ss}";
|
||||||
|
HasUnsavedChanges = true;
|
||||||
|
SaveStatus = "Noch nicht gespeichert.";
|
||||||
StatusText = $"{_participants.Count} Schüler · {groupCount} Gruppen · Größen " +
|
StatusText = $"{_participants.Count} Schüler · {groupCount} Gruppen · Größen " +
|
||||||
string.Join("/", capacities.Distinct().OrderBy(x => x));
|
string.Join("/", capacities.Distinct().OrderBy(x => x));
|
||||||
UpdateWarning(groupCount, assignments);
|
UpdateWarning(groupCount, assignments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||||
|
private void SavePlan()
|
||||||
|
{
|
||||||
|
if (_savedPlans is null || SelectedGroup is null) return;
|
||||||
|
var plan = new StudentGroupPlan
|
||||||
|
{
|
||||||
|
Id = _editingPlanId ?? Guid.NewGuid(),
|
||||||
|
GroupId = SelectedGroup.Id,
|
||||||
|
Name = PlanName,
|
||||||
|
Groups = GeneratedGroups.Select(group => new StudentGroupPlanGroup
|
||||||
|
{
|
||||||
|
StudentIds = group.Members.Select(member => member.Id).ToList(),
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_savedPlans.Save(plan);
|
||||||
|
_editingPlanId = plan.Id;
|
||||||
|
ReloadSavedPlans(plan.Id);
|
||||||
|
HasUnsavedChanges = false;
|
||||||
|
SaveStatus = "Einteilung gespeichert.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
SaveStatus = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanLoadSavedPlan))]
|
||||||
|
private void LoadSavedPlan()
|
||||||
|
{
|
||||||
|
if (_savedPlans is null || SelectedSavedPlan is not { } selected ||
|
||||||
|
SelectedGroup?.Id != selected.GroupId) return;
|
||||||
|
var plan = _savedPlans.GetById(selected.Id);
|
||||||
|
if (plan is null) { ReloadSavedPlans(); return; }
|
||||||
|
_editingPlanId = plan.Id;
|
||||||
|
|
||||||
|
var missing = 0;
|
||||||
|
GeneratedGroups.Clear();
|
||||||
|
foreach (var group in plan.Groups ?? [])
|
||||||
|
{
|
||||||
|
var members = new List<StudentGroupMember>();
|
||||||
|
foreach (var id in group.StudentIds ?? [])
|
||||||
|
{
|
||||||
|
if (_students.GetById(id) is { } student)
|
||||||
|
members.Add(new StudentGroupMember(id, student.FullName));
|
||||||
|
else missing++;
|
||||||
|
}
|
||||||
|
GeneratedGroups.Add(new GeneratedStudentGroup(GeneratedGroups.Count + 1, members));
|
||||||
|
}
|
||||||
|
HasGeneratedGroups = GeneratedGroups.Count > 0;
|
||||||
|
PlanName = plan.Name;
|
||||||
|
HasUnsavedChanges = missing > 0;
|
||||||
|
SaveStatus = missing > 0
|
||||||
|
? $"{missing} nicht mehr vorhandene Schüler wurden ausgelassen. Bitte erneut speichern."
|
||||||
|
: "Gespeicherte Einteilung geladen.";
|
||||||
|
StatusText = $"{GeneratedGroups.Sum(group => group.Members.Count)} Schüler · " +
|
||||||
|
$"{GeneratedGroups.Count} Gruppen · gespeichert am {plan.UpdatedAt.ToLocalTime():dd.MM.yyyy}";
|
||||||
|
WarningText = missing > 0 ? SaveStatus : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanDeleteSavedPlan))]
|
||||||
|
private void DeleteSavedPlan()
|
||||||
|
{
|
||||||
|
if (_savedPlans is null || SelectedSavedPlan is not { } plan) return;
|
||||||
|
_savedPlans.Delete(plan.Id);
|
||||||
|
var deletedCurrent = _editingPlanId == plan.Id;
|
||||||
|
if (deletedCurrent) _editingPlanId = null;
|
||||||
|
ReloadSavedPlans();
|
||||||
|
if (deletedCurrent) HasUnsavedChanges = HasGeneratedGroups;
|
||||||
|
SaveStatus = "Gespeicherte Einteilung gelöscht.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MoveStudent(StudentGroupMember member, GeneratedStudentGroup target)
|
||||||
|
{
|
||||||
|
if (!HasGeneratedGroups || !GeneratedGroups.Contains(target)) return false;
|
||||||
|
var source = GeneratedGroups.FirstOrDefault(group => group.Members.Contains(member));
|
||||||
|
if (source is null || source == target || target.Members.Any(m => m.Id == member.Id)) return false;
|
||||||
|
source.Members.Remove(member);
|
||||||
|
target.Members.Add(member);
|
||||||
|
HasUnsavedChanges = true;
|
||||||
|
SaveStatus = "Änderungen noch nicht gespeichert.";
|
||||||
|
StatusText = $"{GeneratedGroups.Sum(group => group.Members.Count)} Schüler · " +
|
||||||
|
$"{GeneratedGroups.Count} Gruppen · manuell angepasst";
|
||||||
|
var assignments = GeneratedGroups.Select(group => group.Members
|
||||||
|
.Select(m => _students.GetById(m.Id)).OfType<Student>().ToList()).ToList();
|
||||||
|
UpdateWarning(GeneratedGroups.Count, assignments);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReloadSavedPlans(Guid? selectedId = null)
|
||||||
|
{
|
||||||
|
SavedPlans.Clear();
|
||||||
|
SelectedSavedPlan = null;
|
||||||
|
if (_savedPlans is not null && SelectedGroup is not null)
|
||||||
|
{
|
||||||
|
foreach (var plan in _savedPlans.GetByGroup(SelectedGroup.Id)) SavedPlans.Add(plan);
|
||||||
|
SelectedSavedPlan = SavedPlans.FirstOrDefault(plan => plan.Id == selectedId);
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasSavedPlans));
|
||||||
|
}
|
||||||
|
|
||||||
private void ReloadParticipants()
|
private void ReloadParticipants()
|
||||||
{
|
{
|
||||||
_participants.Clear();
|
_participants.Clear();
|
||||||
@@ -385,6 +532,8 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
GeneratedGroups.Clear();
|
GeneratedGroups.Clear();
|
||||||
HasGeneratedGroups = false;
|
HasGeneratedGroups = false;
|
||||||
|
HasUnsavedChanges = false;
|
||||||
|
SaveStatus = "";
|
||||||
StatusText = _participants.Count >= 2
|
StatusText = _participants.Count >= 2
|
||||||
? $"{_participants.Count} Schüler stehen zur Einteilung bereit."
|
? $"{_participants.Count} Schüler stehen zur Einteilung bereit."
|
||||||
: "Für eine Gruppeneinteilung werden mindestens zwei Schüler benötigt.";
|
: "Für eine Gruppeneinteilung werden mindestens zwei Schüler benötigt.";
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class TeachingModeViewModel
|
|||||||
{
|
{
|
||||||
public TeachingTimelineViewModel Timeline { get; }
|
public TeachingTimelineViewModel Timeline { get; }
|
||||||
public string GroupName { get; }
|
public string GroupName { get; }
|
||||||
|
public DateOnly LessonDate { get; }
|
||||||
public LessonViewerViewModel LessonInfo { get; }
|
public LessonViewerViewModel LessonInfo { get; }
|
||||||
public SeatingPlanTabViewModel SeatingPlan { get; }
|
public SeatingPlanTabViewModel SeatingPlan { get; }
|
||||||
/// <summary>Nutzer-Feedback: die Schnellbewertungs-Dialoge für Mitarbeit/Anwesenheit-Hausaufgabe
|
/// <summary>Nutzer-Feedback: die Schnellbewertungs-Dialoge für Mitarbeit/Anwesenheit-Hausaufgabe
|
||||||
@@ -41,6 +42,7 @@ public class TeachingModeViewModel
|
|||||||
TeachingModeOutstandingHomeworkViewModel? outstandingHomework = null)
|
TeachingModeOutstandingHomeworkViewModel? outstandingHomework = null)
|
||||||
{
|
{
|
||||||
GroupName = group.Name;
|
GroupName = group.Name;
|
||||||
|
LessonDate = lesson.Date;
|
||||||
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
|
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
|
||||||
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:lottie="clr-namespace:Avalonia.Labs.Lottie;assembly=Avalonia.Labs.Lottie"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.RandomStudentPickerWindow"
|
x:Class="LehrerApp.Desktop.Views.Groups.RandomStudentPickerWindow"
|
||||||
x:DataType="vm:RandomStudentPickerViewModel"
|
x:DataType="vm:RandomStudentPickerViewModel"
|
||||||
Title="Schüler ziehen"
|
Title="Schüler ziehen"
|
||||||
Width="430" MinHeight="390"
|
Width="430" MinHeight="430"
|
||||||
SizeToContent="Height"
|
SizeToContent="Height"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen">
|
||||||
@@ -16,12 +17,13 @@
|
|||||||
<TextBlock Text="Eigenständiges Fenster für die Bildschirmfreigabe"
|
<TextBlock Text="Eigenständiges Fenster für die Bildschirmfreigabe"
|
||||||
FontSize="12" Opacity="0.55"/>
|
FontSize="12" Opacity="0.55"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="↺" Command="{Binding ResetCommand}"
|
<Button Grid.Column="1" x:Name="ResetButton" Content="↺" Command="{Binding ResetCommand}"
|
||||||
ToolTip.Tip="Ziehung zurücksetzen" FontSize="18"
|
ToolTip.Tip="Ziehung zurücksetzen" FontSize="18"
|
||||||
Width="38" Height="38"/>
|
Width="38" Height="38"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Row="1" x:Name="RevealCard"
|
<Grid Grid.Row="1" MinHeight="230">
|
||||||
|
<Border x:Name="RevealCard"
|
||||||
MinHeight="190" Padding="24"
|
MinHeight="190" Padding="24"
|
||||||
Background="{DynamicResource AppAccentSoftBackgroundBrush}"
|
Background="{DynamicResource AppAccentSoftBackgroundBrush}"
|
||||||
BorderBrush="{DynamicResource AppFilterActiveBorderBrush}"
|
BorderBrush="{DynamicResource AppFilterActiveBorderBrush}"
|
||||||
@@ -42,13 +44,19 @@
|
|||||||
TextAlignment="Center" TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
|
TextAlignment="Center" TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
<lottie:Lottie x:Name="WheelAnimation"
|
||||||
|
Path="/Assets/Animations/LotterySpinWheel.json"
|
||||||
|
RepeatCount="-1" AutoPlay="False"
|
||||||
|
Width="220" Height="220" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" IsVisible="False"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Button Grid.Row="2" Content="🎲 Ziehen" x:Name="DrawButton"
|
<Button Grid.Row="2" Content="🎲 Ziehen" x:Name="DrawButton"
|
||||||
IsEnabled="{Binding CanDraw}" Click="OnDraw"
|
IsEnabled="{Binding CanDraw}" Click="OnDraw"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
FontSize="18" FontWeight="SemiBold" Height="54" Margin="0,16,0,12"/>
|
FontSize="18" FontWeight="SemiBold" Height="54" Margin="0,16,0,12"/>
|
||||||
|
|
||||||
<Expander Grid.Row="3" Header="Optionen" IsExpanded="False">
|
<Expander Grid.Row="3" x:Name="OptionsExpander" Header="Optionen" IsExpanded="False">
|
||||||
<StackPanel Spacing="10" Margin="0,10,0,0">
|
<StackPanel Spacing="10" Margin="0,10,0,0">
|
||||||
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
|
||||||
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
|
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
|
||||||
|
|||||||
@@ -1,26 +1,60 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Labs.Lottie;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
public partial class RandomStudentPickerWindow : Window
|
public partial class RandomStudentPickerWindow : Window
|
||||||
{
|
{
|
||||||
private bool _isAnimating;
|
private bool _isSpinning;
|
||||||
|
private DateTime _spinStartedAt;
|
||||||
|
|
||||||
public RandomStudentPickerWindow() => InitializeComponent();
|
public RandomStudentPickerWindow() => InitializeComponent();
|
||||||
|
|
||||||
private async void OnDraw(object? sender, RoutedEventArgs e)
|
private async void OnDraw(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_isAnimating || DataContext is not RandomStudentPickerViewModel vm ||
|
if (DataContext is not RandomStudentPickerViewModel vm ||
|
||||||
!vm.DrawCommand.CanExecute(null)) return;
|
!vm.DrawCommand.CanExecute(null)) return;
|
||||||
|
|
||||||
_isAnimating = true;
|
|
||||||
var card = this.FindControl<Border>("RevealCard");
|
var card = this.FindControl<Border>("RevealCard");
|
||||||
if (card is not null) card.Opacity = 0.15;
|
var wheel = this.FindControl<Lottie>("WheelAnimation");
|
||||||
await Task.Delay(150);
|
var button = this.FindControl<Button>("DrawButton");
|
||||||
|
if (card is null || wheel is null || button is null) return;
|
||||||
|
|
||||||
|
if (!_isSpinning)
|
||||||
|
{
|
||||||
|
_isSpinning = true;
|
||||||
|
_spinStartedAt = DateTime.UtcNow;
|
||||||
|
card.IsVisible = false;
|
||||||
|
wheel.IsVisible = true;
|
||||||
|
this.FindControl<Expander>("OptionsExpander")!.IsEnabled = false;
|
||||||
|
this.FindControl<Button>("ResetButton")!.IsEnabled = false;
|
||||||
|
button.Content = "Dreht …";
|
||||||
|
// Erst nach dem Layoutwechsel starten, damit das erste Bild sichtbar ist.
|
||||||
|
await Task.Yield();
|
||||||
|
if (!_isSpinning) return;
|
||||||
|
wheel.Start();
|
||||||
|
button.Content = "Ergebnis anzeigen";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Doppelklick soll die Ziehung nicht unbemerkt sofort auflösen.
|
||||||
|
if (DateTime.UtcNow - _spinStartedAt < TimeSpan.FromSeconds(1.2)) return;
|
||||||
|
wheel.Stop();
|
||||||
|
wheel.IsVisible = false;
|
||||||
vm.DrawCommand.Execute(null);
|
vm.DrawCommand.Execute(null);
|
||||||
if (card is not null) card.Opacity = 1;
|
card.IsVisible = true;
|
||||||
_isAnimating = false;
|
this.FindControl<Expander>("OptionsExpander")!.IsEnabled = true;
|
||||||
|
this.FindControl<Button>("ResetButton")!.IsEnabled = true;
|
||||||
|
button.Content = "🎲 Ziehen";
|
||||||
|
_isSpinning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
_isSpinning = false;
|
||||||
|
this.FindControl<Lottie>("WheelAnimation")?.Stop();
|
||||||
|
base.OnClosed(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
|
||||||
|
xmlns:lottie="clr-namespace:Avalonia.Labs.Lottie;assembly=Avalonia.Labs.Lottie"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.StudentGroupGeneratorWindow"
|
x:Class="LehrerApp.Desktop.Views.Groups.StudentGroupGeneratorWindow"
|
||||||
x:DataType="vm:StudentGroupGeneratorViewModel"
|
x:DataType="vm:StudentGroupGeneratorViewModel"
|
||||||
Title="Gruppen zusammenstellen"
|
Title="Gruppen zusammenstellen"
|
||||||
Width="780" Height="650" MinWidth="620" MinHeight="480"
|
Width="780" Height="650" MinWidth="620" MinHeight="480"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen">
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="Border.studentgroup.droptarget">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentTextBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="2"/>
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
|
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
|
||||||
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,16">
|
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,16">
|
||||||
<TextBlock Text="Gruppen zusammenstellen" Classes="dialogtitle"/>
|
<TextBlock Text="Gruppen zusammenstellen" Classes="dialogtitle"/>
|
||||||
@@ -14,7 +23,7 @@
|
|||||||
FontSize="12" Opacity="0.55"/>
|
FontSize="12" Opacity="0.55"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<ScrollViewer Grid.Row="1" x:Name="ResultsScrollViewer">
|
||||||
<Grid x:Name="GroupResults">
|
<Grid x:Name="GroupResults">
|
||||||
<Grid.Transitions>
|
<Grid.Transitions>
|
||||||
<Transitions>
|
<Transitions>
|
||||||
@@ -40,17 +49,26 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:GeneratedStudentGroup">
|
<DataTemplate x:DataType="vm:GeneratedStudentGroup">
|
||||||
<Border Width="220" Margin="7" Padding="16"
|
<Border Width="220" Margin="7" Padding="16" Classes="studentgroup"
|
||||||
|
Classes.droptarget="{Binding IsDropTarget}"
|
||||||
Background="{DynamicResource AppCardBackgroundBrush}"
|
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||||
BorderBrush="{DynamicResource AppCardBorderBrush}"
|
BorderBrush="{DynamicResource AppCardBorderBrush}"
|
||||||
BorderThickness="1" CornerRadius="12">
|
BorderThickness="1" CornerRadius="12"
|
||||||
|
DragDrop.AllowDrop="True" DragDrop.DragEnter="OnGroupDragEnter"
|
||||||
|
DragDrop.DragLeave="OnGroupDragLeave" DragDrop.DragOver="OnGroupDragOver"
|
||||||
|
DragDrop.Drop="OnGroupDrop">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
<TextBlock Text="{Binding Title}" FontSize="16" FontWeight="SemiBold"
|
<TextBlock Text="{Binding Title}" FontSize="16" FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
||||||
<ItemsControl ItemsSource="{Binding Members}">
|
<ItemsControl ItemsSource="{Binding Members}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:StudentGroupMember">
|
<DataTemplate x:DataType="vm:StudentGroupMember">
|
||||||
<TextBlock Text="{Binding Name}" Margin="0,3" TextWrapping="Wrap"/>
|
<Border Background="{DynamicResource AppChipBackgroundBrush}"
|
||||||
|
CornerRadius="6" Padding="8,5" Margin="0,3"
|
||||||
|
PointerPressed="OnMemberPressed" PointerMoved="OnMemberMoved"
|
||||||
|
PointerReleased="OnMemberReleased">
|
||||||
|
<TextBlock Text="{Binding Name}" TextWrapping="Wrap"/>
|
||||||
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
@@ -61,11 +79,41 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
<lottie:Lottie Grid.Row="1" x:Name="TeamworkAnimation"
|
||||||
|
Path="/Assets/Animations/Teamwork.json"
|
||||||
|
RepeatCount="-1" AutoPlay="False"
|
||||||
|
Width="270" Height="270" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" IsVisible="False"/>
|
||||||
|
|
||||||
<StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,10">
|
<StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,10">
|
||||||
<Button Content="Gruppen zusammenstellen" Click="OnGenerate"
|
<Button x:Name="GenerateButton" Content="Gruppen zusammenstellen" Click="OnGenerate"
|
||||||
IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"
|
IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Center" Height="48" FontSize="16" FontWeight="SemiBold"/>
|
HorizontalContentAlignment="Center" Height="48" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
<StackPanel x:Name="PlanActions" Spacing="8">
|
||||||
|
<TextBlock Text="Schüler zwischen den Gruppenkarten ziehen, um die Einteilung anzupassen."
|
||||||
|
FontSize="11" Opacity="0.65" IsVisible="{Binding HasGeneratedGroups}"/>
|
||||||
|
<Grid ColumnDefinitions="*,10,Auto,8,Auto" IsVisible="{Binding HasSavedPlans}">
|
||||||
|
<ComboBox ItemsSource="{Binding SavedPlans}" SelectedItem="{Binding SelectedSavedPlan}"
|
||||||
|
PlaceholderText="Gespeicherte Einteilung auswählen">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:StudentGroupPlan">
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Grid.Column="2" Content="Laden" Click="OnLoadSavedPlan"
|
||||||
|
IsEnabled="{Binding CanLoadSavedPlan}"/>
|
||||||
|
<Button Grid.Column="4" Content="Löschen" Click="OnDeleteSavedPlan"
|
||||||
|
IsEnabled="{Binding CanDeleteSavedPlan}"/>
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="*,10,Auto" IsVisible="{Binding HasGeneratedGroups}">
|
||||||
|
<TextBox Text="{Binding PlanName, Mode=TwoWay}" PlaceholderText="Name der Einteilung"/>
|
||||||
|
<Button Grid.Column="2" Content="Speichern" Command="{Binding SavePlanCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding SaveStatus}" FontSize="11" Opacity="0.7" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding SaveStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel x:Name="ResultSummary" Spacing="8">
|
||||||
<TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center" Opacity="0.65"
|
<TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center" Opacity="0.65"
|
||||||
IsVisible="{Binding HasGeneratedGroups}"/>
|
IsVisible="{Binding HasGeneratedGroups}"/>
|
||||||
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="10,7"
|
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="10,7"
|
||||||
@@ -73,9 +121,10 @@
|
|||||||
<TextBlock Text="{Binding WarningText}" TextWrapping="Wrap" FontSize="12"
|
<TextBlock Text="{Binding WarningText}" TextWrapping="Wrap" FontSize="12"
|
||||||
Foreground="{DynamicResource AppStatusWarningBrush}"/>
|
Foreground="{DynamicResource AppStatusWarningBrush}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Expander Grid.Row="3" Header="Optionen" IsExpanded="False">
|
<Expander Grid.Row="3" x:Name="OptionsExpander" Header="Optionen" IsExpanded="False">
|
||||||
<ScrollViewer MaxHeight="340">
|
<ScrollViewer MaxHeight="340">
|
||||||
<StackPanel Spacing="11" Margin="0,10,6,4">
|
<StackPanel Spacing="11" Margin="0,10,6,4">
|
||||||
<Grid ColumnDefinitions="*,16,*">
|
<Grid ColumnDefinitions="*,16,*">
|
||||||
|
|||||||
@@ -1,25 +1,204 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Labs.Lottie;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
public partial class StudentGroupGeneratorWindow : Window
|
public partial class StudentGroupGeneratorWindow : Window
|
||||||
{
|
{
|
||||||
private bool _isAnimating;
|
private bool _isSpinning;
|
||||||
|
private DateTime _spinStartedAt;
|
||||||
|
private StudentGroupMember? _dragCandidate;
|
||||||
|
private PointerPressedEventArgs? _dragTrigger;
|
||||||
|
private Avalonia.Point _pressPosition;
|
||||||
|
private StudentGroupMember? _draggedMember;
|
||||||
|
private GeneratedStudentGroup? _pendingDropTarget;
|
||||||
|
|
||||||
public StudentGroupGeneratorWindow() => InitializeComponent();
|
public StudentGroupGeneratorWindow() => InitializeComponent();
|
||||||
|
|
||||||
|
public static StudentGroupGeneratorWindow Create(Guid? initialGroupId, bool useTodayAttendance = false)
|
||||||
|
{
|
||||||
|
var vm = new StudentGroupGeneratorViewModel(
|
||||||
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGradeRepository>(),
|
||||||
|
initialGroupId,
|
||||||
|
savedPlans: App.Services.GetRequiredService<IStudentGroupPlanRepository>());
|
||||||
|
if (useTodayAttendance)
|
||||||
|
vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel;
|
||||||
|
return new StudentGroupGeneratorWindow { DataContext = vm };
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_isAnimating || DataContext is not StudentGroupGeneratorViewModel vm ||
|
if (DataContext is not StudentGroupGeneratorViewModel vm ||
|
||||||
!vm.GenerateCommand.CanExecute(null)) return;
|
!vm.GenerateCommand.CanExecute(null)) return;
|
||||||
_isAnimating = true;
|
var results = this.FindControl<ScrollViewer>("ResultsScrollViewer");
|
||||||
var results = this.FindControl<Grid>("GroupResults");
|
var teamwork = this.FindControl<Lottie>("TeamworkAnimation");
|
||||||
if (results is not null) results.Opacity = 0.15;
|
var button = this.FindControl<Button>("GenerateButton");
|
||||||
await Task.Delay(140);
|
var options = this.FindControl<Expander>("OptionsExpander");
|
||||||
vm.GenerateCommand.Execute(null);
|
var summary = this.FindControl<StackPanel>("ResultSummary");
|
||||||
if (results is not null) results.Opacity = 1;
|
var actions = this.FindControl<StackPanel>("PlanActions");
|
||||||
_isAnimating = false;
|
if (results is null || teamwork is null || button is null || options is null ||
|
||||||
|
summary is null || actions is null) return;
|
||||||
|
|
||||||
|
if (!_isSpinning)
|
||||||
|
{
|
||||||
|
_isSpinning = true;
|
||||||
|
_spinStartedAt = DateTime.UtcNow;
|
||||||
|
results.IsVisible = false;
|
||||||
|
actions.IsVisible = false;
|
||||||
|
summary.IsVisible = false;
|
||||||
|
teamwork.IsVisible = true;
|
||||||
|
options.IsEnabled = false;
|
||||||
|
button.Content = "Teamwork läuft …";
|
||||||
|
// Nach dem Layoutwechsel starten, damit der erste Frame sichtbar ist.
|
||||||
|
await Task.Yield();
|
||||||
|
if (!_isSpinning) return;
|
||||||
|
teamwork.Start();
|
||||||
|
button.Content = "Ergebnis anzeigen";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Doppelklick soll die Animation nicht sofort wieder schließen.
|
||||||
|
if (DateTime.UtcNow - _spinStartedAt < TimeSpan.FromSeconds(1.2)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
vm.GenerateCommand.Execute(null);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
teamwork.Stop();
|
||||||
|
teamwork.IsVisible = false;
|
||||||
|
results.IsVisible = true;
|
||||||
|
actions.IsVisible = true;
|
||||||
|
summary.IsVisible = true;
|
||||||
|
options.IsEnabled = true;
|
||||||
|
button.Content = "Gruppen zusammenstellen";
|
||||||
|
_isSpinning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMemberPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isSpinning || sender is not Control { DataContext: StudentGroupMember member } control ||
|
||||||
|
!e.GetCurrentPoint(control).Properties.IsLeftButtonPressed) return;
|
||||||
|
_dragCandidate = member;
|
||||||
|
_dragTrigger = e;
|
||||||
|
_pressPosition = e.GetPosition(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnLoadSavedPlan(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not StudentGroupGeneratorViewModel vm ||
|
||||||
|
!vm.LoadSavedPlanCommand.CanExecute(null)) return;
|
||||||
|
if (vm.HasUnsavedChanges)
|
||||||
|
{
|
||||||
|
var dialog = new ConfirmDialog
|
||||||
|
{
|
||||||
|
DataContext = new ConfirmDialogInfo
|
||||||
|
{
|
||||||
|
Title = "Ungespeicherte Einteilung ersetzen?",
|
||||||
|
Message = "Die aktuelle Einteilung und manuelle Änderungen werden beim Laden verworfen.",
|
||||||
|
ConfirmText = "Gespeicherte Einteilung laden",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!await dialog.ShowDialog<bool>(this)) return;
|
||||||
|
}
|
||||||
|
vm.LoadSavedPlanCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDeleteSavedPlan(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not StudentGroupGeneratorViewModel vm ||
|
||||||
|
!vm.DeleteSavedPlanCommand.CanExecute(null)) return;
|
||||||
|
var dialog = new ConfirmDialog
|
||||||
|
{
|
||||||
|
DataContext = new ConfirmDialogInfo
|
||||||
|
{
|
||||||
|
Title = "Einteilung löschen?",
|
||||||
|
Message = $"Die gespeicherte Einteilung „{vm.SelectedSavedPlan!.Name}“ wird gelöscht.",
|
||||||
|
ConfirmText = "Löschen",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (await dialog.ShowDialog<bool>(this)) vm.DeleteSavedPlanCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnMemberMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (_dragCandidate is null || _dragTrigger is null ||
|
||||||
|
!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
||||||
|
var position = e.GetPosition(this);
|
||||||
|
if (Math.Abs(position.X - _pressPosition.X) < 6 &&
|
||||||
|
Math.Abs(position.Y - _pressPosition.Y) < 6) return;
|
||||||
|
|
||||||
|
_draggedMember = _dragCandidate;
|
||||||
|
_pendingDropTarget = null;
|
||||||
|
_dragCandidate = null;
|
||||||
|
var trigger = _dragTrigger;
|
||||||
|
_dragTrigger = null;
|
||||||
|
var data = new DataTransfer();
|
||||||
|
data.Add(DataTransferItem.CreateText("LehrerApp-Gruppeneinteilung"));
|
||||||
|
var effect = await DragDrop.DoDragDropAsync(trigger, data, DragDropEffects.Move);
|
||||||
|
var member = _draggedMember;
|
||||||
|
var target = _pendingDropTarget;
|
||||||
|
_draggedMember = null;
|
||||||
|
_pendingDropTarget = null;
|
||||||
|
if ((effect & DragDropEffects.Move) == 0 || member is null || target is null) return;
|
||||||
|
// Die Quelle erst verändern, wenn Avalonia den Drop vollständig verarbeitet hat.
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (DataContext is StudentGroupGeneratorViewModel vm) vm.MoveStudent(member, target);
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMemberReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
_dragCandidate = null;
|
||||||
|
_dragTrigger = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanDropOn(GeneratedStudentGroup target) =>
|
||||||
|
!_isSpinning && _draggedMember is { } member &&
|
||||||
|
!target.Members.Any(existing => existing.Id == member.Id);
|
||||||
|
|
||||||
|
private void OnGroupDragEnter(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Border { DataContext: GeneratedStudentGroup target } && CanDropOn(target))
|
||||||
|
target.IsDropTarget = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGroupDragLeave(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Border { DataContext: GeneratedStudentGroup target }) target.IsDropTarget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGroupDragOver(object? sender, DragEventArgs e) =>
|
||||||
|
e.DragEffects = sender is Border { DataContext: GeneratedStudentGroup target } && CanDropOn(target)
|
||||||
|
? DragDropEffects.Move : DragDropEffects.None;
|
||||||
|
|
||||||
|
private void OnGroupDrop(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Border { DataContext: GeneratedStudentGroup target }) return;
|
||||||
|
target.IsDropTarget = false;
|
||||||
|
if (!CanDropOn(target)) { e.DragEffects = DragDropEffects.None; return; }
|
||||||
|
_pendingDropTarget = target;
|
||||||
|
e.DragEffects = DragDropEffects.Move;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
_isSpinning = false;
|
||||||
|
this.FindControl<Lottie>("TeamworkAnimation")?.Stop();
|
||||||
|
base.OnClosed(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,8 @@
|
|||||||
<Button Content="Hausaufgaben kontrollieren" Command="{Binding SeatingPlan.CheckHomeworkCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
|
<Button Content="Hausaufgaben kontrollieren" Command="{Binding SeatingPlan.CheckHomeworkCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
|
||||||
<Button Content="Kontrolle beenden" Command="{Binding SeatingPlan.EndQuickCheckCommand}" IsVisible="{Binding SeatingPlan.IsQuickMode}"/>
|
<Button Content="Kontrolle beenden" Command="{Binding SeatingPlan.EndQuickCheckCommand}" IsVisible="{Binding SeatingPlan.IsQuickMode}"/>
|
||||||
<Button Content="Listenansicht …" Command="{Binding Participation.StatusQuickInputCommand}" ToolTip.Tip="Auch Schüler ohne Sitzplatz erfassen."/>
|
<Button Content="Listenansicht …" Command="{Binding Participation.StatusQuickInputCommand}" ToolTip.Tip="Auch Schüler ohne Sitzplatz erfassen."/>
|
||||||
|
<Button Content="Gruppen zusammenstellen …" Click="OnOpenStudentGroups"
|
||||||
|
ToolTip.Tip="Gruppen für diese Lerngruppe ziehen, bearbeiten und später wieder laden."/>
|
||||||
<Button Content="Vollbild ↔" Click="OnFullScreen" ToolTip.Tip="Vollbild umschalten (F11); mit Escape verlassen."/>
|
<Button Content="Vollbild ↔" Click="OnFullScreen" ToolTip.Tip="Vollbild umschalten (F11); mit Escape verlassen."/>
|
||||||
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
|
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
|
||||||
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
|
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public partial class TeachingModeWindow : Window
|
|||||||
private static readonly Dictionary<Guid, TeachingModeWindow> OpenWindows = [];
|
private static readonly Dictionary<Guid, TeachingModeWindow> OpenWindows = [];
|
||||||
private readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromSeconds(1) };
|
private readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||||
private WindowState _previousState = WindowState.Maximized;
|
private WindowState _previousState = WindowState.Maximized;
|
||||||
|
private StudentGroupGeneratorWindow? _studentGroupGeneratorWindow;
|
||||||
|
|
||||||
public static void Open(Lesson lesson)
|
public static void Open(Lesson lesson)
|
||||||
{
|
{
|
||||||
@@ -66,6 +67,20 @@ public partial class TeachingModeWindow : Window
|
|||||||
}
|
}
|
||||||
private void OnFullScreen(object? sender, RoutedEventArgs e) => ToggleFullScreen();
|
private void OnFullScreen(object? sender, RoutedEventArgs e) => ToggleFullScreen();
|
||||||
|
|
||||||
|
private void OnOpenStudentGroups(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_studentGroupGeneratorWindow is not null)
|
||||||
|
{
|
||||||
|
_studentGroupGeneratorWindow.Activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DataContext is not TeachingModeViewModel vm) return;
|
||||||
|
_studentGroupGeneratorWindow = StudentGroupGeneratorWindow.Create(vm.LessonInfo.GroupId,
|
||||||
|
useTodayAttendance: vm.LessonDate == DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
_studentGroupGeneratorWindow.Closed += (_, _) => _studentGroupGeneratorWindow = null;
|
||||||
|
_studentGroupGeneratorWindow.Show(this);
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnDataContextChanged(EventArgs e)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnDataContextChanged(e);
|
base.OnDataContextChanged(e);
|
||||||
|
|||||||
@@ -103,15 +103,7 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentGroupId = (DataContext as MainWindowViewModel)?.CurrentGroupDetail?.Group?.Id;
|
var currentGroupId = (DataContext as MainWindowViewModel)?.CurrentGroupDetail?.Group?.Id;
|
||||||
var vm = new StudentGroupGeneratorViewModel(
|
_studentGroupGeneratorWindow = StudentGroupGeneratorWindow.Create(currentGroupId);
|
||||||
App.Services.GetRequiredService<IStudentRepository>(),
|
|
||||||
App.Services.GetRequiredService<IGroupRepository>(),
|
|
||||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
|
||||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
|
||||||
App.Services.GetRequiredService<IParticipationRepository>(),
|
|
||||||
App.Services.GetRequiredService<IGradeRepository>(),
|
|
||||||
currentGroupId);
|
|
||||||
_studentGroupGeneratorWindow = new StudentGroupGeneratorWindow { DataContext = vm };
|
|
||||||
_studentGroupGeneratorWindow.Closed += (_, _) => _studentGroupGeneratorWindow = null;
|
_studentGroupGeneratorWindow.Closed += (_, _) => _studentGroupGeneratorWindow = null;
|
||||||
_studentGroupGeneratorWindow.Show();
|
_studentGroupGeneratorWindow.Show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ public sealed class EventApplierTests
|
|||||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||||
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyAsync_StudentGroupPlan_SynchronisiertEinteilungUndLoeschung()
|
||||||
|
{
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var applier = new EventApplier(db, Key);
|
||||||
|
var studentId = Guid.NewGuid();
|
||||||
|
var plan = new StudentGroupPlan
|
||||||
|
{
|
||||||
|
GroupId = Guid.NewGuid(), Name = "Projektwoche",
|
||||||
|
Groups = [new StudentGroupPlanGroup { StudentIds = [studentId] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await applier.ApplyAsync(MakeEvent(nameof(StudentGroupPlan), plan.Id.ToString(), "Save", plan));
|
||||||
|
Assert.Equal(studentId, db.StudentGroupPlans.FindById(plan.Id)!.Groups[0].StudentIds[0]);
|
||||||
|
|
||||||
|
await applier.ApplyAsync(MakeEvent(nameof(StudentGroupPlan), plan.Id.ToString(), "Delete", null));
|
||||||
|
Assert.Null(db.StudentGroupPlans.FindById(plan.Id));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection()
|
public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
|
|||||||
|
|
||||||
Simple<Student>(context => context.Students);
|
Simple<Student>(context => context.Students);
|
||||||
Simple<SeatingPlan>(context => context.SeatingPlans);
|
Simple<SeatingPlan>(context => context.SeatingPlans);
|
||||||
|
Simple<StudentGroupPlan>(context => context.StudentGroupPlans);
|
||||||
Simple<GroupMembership>(context => context.Memberships);
|
Simple<GroupMembership>(context => context.Memberships);
|
||||||
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
|
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
|
||||||
Simple<Grade>(context => context.Grades);
|
Simple<Grade>(context => context.Grades);
|
||||||
|
|||||||
Reference in new issue
Block a user