This commit is contained in:
1 parent
c79d96c5ec
commit
d4b1419f9a
7 files changed
+951
No files matched your search
@@ -0,0 +1,185 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class StudentGroupGeneratorViewModelTests
|
||||
{
|
||||
private static (StudentGroupGeneratorViewModel Vm, List<Student> Students, LearningGroup Group,
|
||||
FakeSessions Sessions, FakeEntries Entries) Build(int studentCount = 10)
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a", GradingSystem = GradingSystem.Grades1To6 };
|
||||
var students = Enumerable.Range(1, studentCount).Select(i => new Student
|
||||
{
|
||||
FirstName = $"Kind{i:00}", LastName = "Test",
|
||||
}).ToList();
|
||||
var memberships = students.Select(s => new GroupMembership
|
||||
{
|
||||
GroupId = group.Id, StudentId = s.Id,
|
||||
}).ToList();
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new StudentGroupGeneratorViewModel(
|
||||
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
|
||||
sessions, entries, new FakeGrades(), group.Id, new Random(7));
|
||||
return (vm, students, group, sessions, entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FesteZielgroesse_ErzeugtPassendeAnzahlMitKleinerAusnahme()
|
||||
{
|
||||
var (vm, _, _, _, _) = Build(10);
|
||||
vm.TargetGroupSize = 4;
|
||||
vm.TargetGroupCount = 2;
|
||||
vm.SizePriority = 100;
|
||||
vm.CountPriority = 20;
|
||||
|
||||
vm.GenerateCommand.Execute(null);
|
||||
|
||||
Assert.Equal(3, vm.GeneratedGroups.Count);
|
||||
Assert.Equal(new[] { 3, 3, 4 }, vm.GeneratedGroups.Select(g => g.Members.Count).OrderBy(x => x));
|
||||
Assert.Contains("nicht gleichzeitig", vm.WarningText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FesteGruppenanzahl_HatVorrangVorZielgroesse()
|
||||
{
|
||||
var (vm, _, _, _, _) = Build(10);
|
||||
vm.TargetGroupSize = 3;
|
||||
vm.TargetGroupCount = 2;
|
||||
vm.SizePriority = 20;
|
||||
vm.CountPriority = 100;
|
||||
|
||||
vm.GenerateCommand.Execute(null);
|
||||
|
||||
Assert.Equal(2, vm.GeneratedGroups.Count);
|
||||
Assert.All(vm.GeneratedGroups, group => Assert.Equal(5, group.Members.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdHoc_SchliesstHeuteAbwesendeAus()
|
||||
{
|
||||
var (vm, students, group, sessions, entries) = Build(6);
|
||||
var session = new ParticipationSession { GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today) };
|
||||
sessions.Save(session);
|
||||
entries.Add(new ParticipationEntry
|
||||
{
|
||||
SessionId = session.Id, StudentId = students[0].Id, Attendance = AttendanceStatus.Excused,
|
||||
});
|
||||
vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel;
|
||||
vm.TargetGroupCount = 2;
|
||||
vm.CountPriority = 100;
|
||||
|
||||
vm.GenerateCommand.Execute(null);
|
||||
|
||||
var names = vm.GeneratedGroups.SelectMany(g => g.Members).Select(m => m.Name).ToList();
|
||||
Assert.Equal(5, names.Count);
|
||||
Assert.DoesNotContain(students[0].FullName, names);
|
||||
Assert.Contains("fehlt heute", vm.WarningText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdHoc_OhneHeutigeErfassung_WarntUndNutztAlleAktiven()
|
||||
{
|
||||
var (vm, students, _, _, _) = Build(6);
|
||||
vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel;
|
||||
|
||||
vm.GenerateCommand.Execute(null);
|
||||
|
||||
Assert.Equal(students.Count, vm.GeneratedGroups.SelectMany(g => g.Members).Count());
|
||||
Assert.Contains("keine Anwesenheit", vm.WarningText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leistungsverteilung_UnterscheidetHomogeneUndHeterogeneGruppen()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Kurs", GradingSystem = GradingSystem.Grades1To6 };
|
||||
var students = new[] { "A", "B", "C", "D" }.Select(name => new Student
|
||||
{
|
||||
FirstName = name, LastName = "Test",
|
||||
}).ToList();
|
||||
var memberships = students.Select(s => new GroupMembership { GroupId = group.Id, StudentId = s.Id }).ToList();
|
||||
var grades = new FakeGrades();
|
||||
var values = new[] { "1", "2", "5", "6" };
|
||||
for (var i = 0; i < students.Count; i++)
|
||||
grades.Add(new Grade { GroupId = group.Id, StudentId = students[i].Id, Value = values[i] });
|
||||
var vm = new StudentGroupGeneratorViewModel(
|
||||
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
|
||||
new FakeSessions([]), new FakeEntries(), grades, group.Id, new Random(3))
|
||||
{
|
||||
TargetGroupCount = 2,
|
||||
CountPriority = 100,
|
||||
PerformanceDistribution = -100,
|
||||
};
|
||||
|
||||
vm.GenerateCommand.Execute(null);
|
||||
var homogeneousFirst = vm.GeneratedGroups[0].Members.Select(m => m.Name).ToHashSet();
|
||||
Assert.Contains(students[0].FullName, homogeneousFirst);
|
||||
Assert.Contains(students[1].FullName, homogeneousFirst);
|
||||
|
||||
vm.PerformanceDistribution = 100;
|
||||
vm.GenerateCommand.Execute(null);
|
||||
Assert.All(vm.GeneratedGroups, generated =>
|
||||
{
|
||||
var names = generated.Members.Select(m => m.Name).ToHashSet();
|
||||
Assert.True(names.Overlaps([students[0].FullName, students[1].FullName]));
|
||||
Assert.True(names.Overlaps([students[2].FullName, students[3].FullName]));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paarregeln_ErzwingenZusammenUndTrennenMitHoherPrioritaet()
|
||||
{
|
||||
var (vm, students, _, _, _) = Build(6);
|
||||
vm.TargetGroupCount = 2;
|
||||
vm.CountPriority = 100;
|
||||
vm.PerformanceDistribution = 0;
|
||||
vm.ForcedPairPriority = 100;
|
||||
vm.SelectedPairStudentA = vm.PairCandidates.Single(s => s.Id == students[0].Id);
|
||||
vm.SelectedPairStudentB = vm.PairCandidates.Single(s => s.Id == students[1].Id);
|
||||
vm.AddForcedPairCommand.Execute(null);
|
||||
vm.SelectedPairStudentA = vm.PairCandidates.Single(s => s.Id == students[2].Id);
|
||||
vm.SelectedPairStudentB = vm.PairCandidates.Single(s => s.Id == students[3].Id);
|
||||
vm.AddExcludedPairCommand.Execute(null);
|
||||
|
||||
vm.GenerateCommand.Execute(null);
|
||||
|
||||
var groupByName = vm.GeneratedGroups.SelectMany((group, index) =>
|
||||
group.Members.Select(member => (member.Name, index)))
|
||||
.ToDictionary(x => x.Name, x => x.index);
|
||||
Assert.Equal(groupByName[students[0].FullName], groupByName[students[1].FullName]);
|
||||
Assert.NotEqual(groupByName[students[2].FullName], groupByName[students[3].FullName]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoerderschuelerWerdenMoeglichstGleichmaessigVerteilt()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Kurs" };
|
||||
var students = Enumerable.Range(1, 8).Select(i => new Student
|
||||
{
|
||||
FirstName = $"Kind{i}", LastName = "Test",
|
||||
}).ToList();
|
||||
var memberships = students.Select((student, index) => new GroupMembership
|
||||
{
|
||||
GroupId = group.Id,
|
||||
StudentId = student.Id,
|
||||
Niveau = index < 4 ? Niveau.Foerder : null,
|
||||
}).ToList();
|
||||
var vm = new StudentGroupGeneratorViewModel(
|
||||
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
|
||||
new FakeSessions([]), new FakeEntries(), new FakeGrades(), group.Id, new Random(4))
|
||||
{
|
||||
TargetGroupCount = 2,
|
||||
CountPriority = 100,
|
||||
PerformanceDistribution = -100,
|
||||
DistributeSupportStudents = true,
|
||||
};
|
||||
|
||||
vm.GenerateCommand.Execute(null);
|
||||
|
||||
var supportNames = students.Take(4).Select(s => s.FullName).ToHashSet();
|
||||
Assert.All(vm.GeneratedGroups, generated =>
|
||||
Assert.Equal(2, generated.Members.Count(member => supportNames.Contains(member.Name))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
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 sealed record StudentGroupMember(string Name);
|
||||
public sealed record StudentPairCandidate(Guid Id, string Name);
|
||||
|
||||
public sealed class StudentPairRule
|
||||
{
|
||||
public Guid FirstId { get; }
|
||||
public Guid SecondId { get; }
|
||||
public string Display { get; }
|
||||
public IRelayCommand RemoveCommand { get; }
|
||||
|
||||
public StudentPairRule(StudentPairCandidate first, StudentPairCandidate second, Action<StudentPairRule> remove)
|
||||
{
|
||||
FirstId = first.Id;
|
||||
SecondId = second.Id;
|
||||
Display = $"{first.Name} + {second.Name}";
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
}
|
||||
|
||||
public bool Matches(Guid first, Guid second) =>
|
||||
FirstId == first && SecondId == second || FirstId == second && SecondId == first;
|
||||
}
|
||||
|
||||
public sealed class GeneratedStudentGroup
|
||||
{
|
||||
public string Title { get; }
|
||||
public ObservableCollection<StudentGroupMember> Members { get; } = [];
|
||||
|
||||
public GeneratedStudentGroup(int number, IEnumerable<Student> students)
|
||||
{
|
||||
Title = $"Gruppe {number}";
|
||||
foreach (var student in students.OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase))
|
||||
Members.Add(new StudentGroupMember(student.FullName));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Erzeugt Gruppen fuer eine spaetere Planung oder aus der heutigen Anwesenheit.</summary>
|
||||
public partial class StudentGroupGeneratorViewModel : ObservableObject
|
||||
{
|
||||
public const string FutureModeLabel = "Für später · alle Aktiven";
|
||||
public const string AdHocModeLabel = "Ad hoc · heute Anwesende";
|
||||
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupMembershipRepository _memberships;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly Random _random;
|
||||
private readonly List<Student> _participants = [];
|
||||
private readonly Dictionary<Guid, double?> _performance = [];
|
||||
private readonly HashSet<Guid> _supportStudentIds = [];
|
||||
private string _attendanceWarning = "";
|
||||
|
||||
[ObservableProperty] private StudentDrawGroup? _selectedGroup;
|
||||
[ObservableProperty] private string _participantModeName = FutureModeLabel;
|
||||
[ObservableProperty] private int _targetGroupSize = 4;
|
||||
[ObservableProperty] private int _targetGroupCount = 6;
|
||||
[ObservableProperty] private double _sizePriority = 75;
|
||||
[ObservableProperty] private double _countPriority = 50;
|
||||
[ObservableProperty] private double _performanceDistribution = 65;
|
||||
[ObservableProperty] private bool _distributeSupportStudents = true;
|
||||
[ObservableProperty] private StudentPairCandidate? _selectedPairStudentA;
|
||||
[ObservableProperty] private StudentPairCandidate? _selectedPairStudentB;
|
||||
[ObservableProperty] private double _forcedPairPriority = 85;
|
||||
[ObservableProperty] private double _excludedPairPriority = 85;
|
||||
[ObservableProperty] private string _pairingMessage = "";
|
||||
[ObservableProperty] private string _statusText = "Optionen wählen und Gruppen zusammenstellen.";
|
||||
[ObservableProperty] private string _warningText = "";
|
||||
[ObservableProperty] private bool _hasGeneratedGroups;
|
||||
|
||||
public ObservableCollection<StudentDrawGroup> Groups { get; } = [];
|
||||
public ObservableCollection<GeneratedStudentGroup> GeneratedGroups { get; } = [];
|
||||
public ObservableCollection<StudentPairCandidate> PairCandidates { get; } = [];
|
||||
public ObservableCollection<StudentPairRule> ForcedPairs { get; } = [];
|
||||
public ObservableCollection<StudentPairRule> ExcludedPairs { get; } = [];
|
||||
public string[] ParticipantModeOptions { get; } = [FutureModeLabel, AdHocModeLabel];
|
||||
public bool CanGenerate => SelectedGroup is not null && _participants.Count >= 2;
|
||||
public string SizePriorityLabel => PriorityLabel(SizePriority);
|
||||
public string CountPriorityLabel => PriorityLabel(CountPriority);
|
||||
public string PerformanceDistributionLabel => PerformanceDistribution switch
|
||||
{
|
||||
<= -70 => "stark homogen",
|
||||
< -20 => "eher homogen",
|
||||
<= 20 => "zufällig gemischt",
|
||||
< 70 => "eher heterogen",
|
||||
_ => "stark heterogen",
|
||||
};
|
||||
public string ForcedPairPriorityLabel => PriorityLabel(ForcedPairPriority);
|
||||
public string ExcludedPairPriorityLabel => PriorityLabel(ExcludedPairPriority);
|
||||
|
||||
public StudentGroupGeneratorViewModel(
|
||||
IStudentRepository students,
|
||||
IGroupRepository groups,
|
||||
IGroupMembershipRepository memberships,
|
||||
IParticipationSessionRepository sessions,
|
||||
IParticipationRepository participation,
|
||||
IGradeRepository grades,
|
||||
Guid? initialGroupId = null,
|
||||
Random? random = null)
|
||||
{
|
||||
_students = students;
|
||||
_groupRepository = groups;
|
||||
_memberships = memberships;
|
||||
_sessions = sessions;
|
||||
_participation = participation;
|
||||
_grades = grades;
|
||||
_random = random ?? Random.Shared;
|
||||
|
||||
foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
Groups.Add(new StudentDrawGroup(group.Id, group.Name));
|
||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedGroupChanged(StudentDrawGroup? value)
|
||||
{
|
||||
ForcedPairs.Clear();
|
||||
ExcludedPairs.Clear();
|
||||
ReloadParticipants();
|
||||
}
|
||||
partial void OnParticipantModeNameChanged(string value) => ReloadParticipants();
|
||||
partial void OnTargetGroupSizeChanged(int value) => InvalidateResult();
|
||||
partial void OnTargetGroupCountChanged(int value) => InvalidateResult();
|
||||
partial void OnSizePriorityChanged(double value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SizePriorityLabel));
|
||||
InvalidateResult();
|
||||
}
|
||||
partial void OnCountPriorityChanged(double value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CountPriorityLabel));
|
||||
InvalidateResult();
|
||||
}
|
||||
partial void OnPerformanceDistributionChanged(double value)
|
||||
{
|
||||
OnPropertyChanged(nameof(PerformanceDistributionLabel));
|
||||
InvalidateResult();
|
||||
}
|
||||
partial void OnDistributeSupportStudentsChanged(bool value) => InvalidateResult();
|
||||
partial void OnForcedPairPriorityChanged(double value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ForcedPairPriorityLabel));
|
||||
InvalidateResult();
|
||||
}
|
||||
partial void OnExcludedPairPriorityChanged(double value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ExcludedPairPriorityLabel));
|
||||
InvalidateResult();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddForcedPair() => AddPairRule(ForcedPairs, ExcludedPairs);
|
||||
|
||||
[RelayCommand]
|
||||
private void AddExcludedPair() => AddPairRule(ExcludedPairs, ForcedPairs);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGenerate))]
|
||||
private void Generate()
|
||||
{
|
||||
var groupCount = ResolveGroupCount(_participants.Count);
|
||||
var capacities = Enumerable.Range(0, groupCount)
|
||||
.Select(i => _participants.Count / groupCount + (i < _participants.Count % groupCount ? 1 : 0))
|
||||
.ToArray();
|
||||
var assignments = BuildAssignments(capacities);
|
||||
|
||||
GeneratedGroups.Clear();
|
||||
for (var i = 0; i < assignments.Count; i++)
|
||||
GeneratedGroups.Add(new GeneratedStudentGroup(i + 1, assignments[i]));
|
||||
|
||||
HasGeneratedGroups = true;
|
||||
StatusText = $"{_participants.Count} Schüler · {groupCount} Gruppen · Größen " +
|
||||
string.Join("/", capacities.Distinct().OrderBy(x => x));
|
||||
UpdateWarning(groupCount, assignments);
|
||||
}
|
||||
|
||||
private void ReloadParticipants()
|
||||
{
|
||||
_participants.Clear();
|
||||
_performance.Clear();
|
||||
_supportStudentIds.Clear();
|
||||
PairCandidates.Clear();
|
||||
_attendanceWarning = "";
|
||||
|
||||
if (SelectedGroup is not null)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var activeMemberships = _memberships.GetByGroup(SelectedGroup.Id)
|
||||
.Where(m => GroupMembershipService.IsActiveOn(m, today))
|
||||
.ToList();
|
||||
var activeIds = activeMemberships.Select(m => m.StudentId).ToHashSet();
|
||||
foreach (var membership in activeMemberships.Where(m => m.Niveau == Niveau.Foerder))
|
||||
_supportStudentIds.Add(membership.StudentId);
|
||||
var active = _students.GetByGroup(SelectedGroup.Id)
|
||||
.Where(s => s.IsActive && activeIds.Contains(s.Id)).ToList();
|
||||
_participants.AddRange(ParticipantModeName == AdHocModeLabel
|
||||
? FilterByTodayAttendance(SelectedGroup.Id, active, today)
|
||||
: active);
|
||||
LoadPerformance(SelectedGroup.Id);
|
||||
foreach (var student in _participants.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
PairCandidates.Add(new StudentPairCandidate(student.Id, student.FullName));
|
||||
}
|
||||
|
||||
SelectedPairStudentA = null;
|
||||
SelectedPairStudentB = null;
|
||||
|
||||
InvalidateResult();
|
||||
OnPropertyChanged(nameof(CanGenerate));
|
||||
GenerateCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private IEnumerable<Student> FilterByTodayAttendance(Guid groupId, List<Student> active, DateOnly today)
|
||||
{
|
||||
var todaySessions = _sessions.GetByGroup(groupId).Where(s => s.Date == today)
|
||||
.OrderBy(s => s.CreatedAt).ToList();
|
||||
if (todaySessions.Count == 0)
|
||||
{
|
||||
_attendanceWarning = "Heute wurde noch keine Anwesenheit erfasst; deshalb werden alle aktiven Schüler einbezogen.";
|
||||
return active;
|
||||
}
|
||||
|
||||
var latestStatus = new Dictionary<Guid, AttendanceStatus?>();
|
||||
foreach (var session in todaySessions)
|
||||
foreach (var entry in _participation.GetBySession(session.Id))
|
||||
if (entry.Attendance is not null) latestStatus[entry.StudentId] = entry.Attendance;
|
||||
|
||||
var unknown = active.Count(s => !latestStatus.ContainsKey(s.Id));
|
||||
if (unknown > 0)
|
||||
_attendanceWarning = $"Für {unknown} Schüler fehlt heute ein Anwesenheitsstatus; sie werden vorsichtshalber einbezogen.";
|
||||
return active.Where(s => !latestStatus.TryGetValue(s.Id, out var status) || status == AttendanceStatus.Present);
|
||||
}
|
||||
|
||||
private void LoadPerformance(Guid groupId)
|
||||
{
|
||||
var system = _groupRepository.GetById(groupId)?.GradingSystem ?? GradingSystem.Grades1To6;
|
||||
var gradesByStudent = _grades.GetByGroup(groupId).GroupBy(g => g.StudentId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => ParseGradeScore(x.Value, system)).Where(x => x.HasValue)
|
||||
.Select(x => x!.Value).ToList());
|
||||
var entries = _sessions.GetByGroup(groupId).SelectMany(s => _participation.GetBySession(s.Id)).ToList();
|
||||
|
||||
foreach (var student in _participants)
|
||||
{
|
||||
var signals = gradesByStudent.GetValueOrDefault(student.Id, []).ToList();
|
||||
signals.AddRange(entries.Where(e => e.StudentId == student.Id).SelectMany(e => e.Ratings)
|
||||
.Select(r => (Math.Clamp(r.Value, -2, 2) + 2) / 4.0));
|
||||
_performance[student.Id] = signals.Count == 0 ? null : signals.Average();
|
||||
}
|
||||
}
|
||||
|
||||
private List<List<Student>> BuildAssignments(int[] capacities)
|
||||
{
|
||||
var result = capacities.Select(_ => new List<Student>()).ToList();
|
||||
var strength = Math.Abs(PerformanceDistribution) / 100.0;
|
||||
var mean = _performance.Values.Where(x => x.HasValue).Select(x => x!.Value).DefaultIfEmpty(0.5).Average();
|
||||
var ranked = _participants.Select(s => new
|
||||
{
|
||||
Student = s,
|
||||
Score = (_performance.GetValueOrDefault(s.Id) ?? mean) * strength + _random.NextDouble() * (1 - strength),
|
||||
}).OrderByDescending(x => x.Score).ToList();
|
||||
|
||||
if (PerformanceDistribution < -20)
|
||||
{
|
||||
var cursor = 0;
|
||||
for (var i = 0; i < result.Count; i++)
|
||||
for (var j = 0; j < capacities[i]; j++) result[i].Add(ranked[cursor++].Student);
|
||||
}
|
||||
else if (PerformanceDistribution > 20)
|
||||
{
|
||||
var sums = new double[result.Count];
|
||||
foreach (var item in ranked)
|
||||
{
|
||||
var target = Enumerable.Range(0, result.Count).Where(i => result[i].Count < capacities[i])
|
||||
.OrderBy(i => result[i].Count == 0 ? -1 : sums[i] / result[i].Count)
|
||||
.ThenBy(_ => _random.NextDouble()).First();
|
||||
result[target].Add(item.Student);
|
||||
sums[target] += item.Score;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var shuffled = ranked.OrderBy(_ => _random.Next()).Select(x => x.Student).ToList();
|
||||
var cursor = 0;
|
||||
for (var i = 0; i < result.Count; i++)
|
||||
for (var j = 0; j < capacities[i]; j++) result[i].Add(shuffled[cursor++]);
|
||||
}
|
||||
OptimizeAssignments(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void OptimizeAssignments(List<List<Student>> groups)
|
||||
{
|
||||
var best = AssignmentPenalty(groups);
|
||||
var improved = true;
|
||||
var passes = 0;
|
||||
while (improved && passes++ < 100)
|
||||
{
|
||||
improved = false;
|
||||
(int AGroup, int AIndex, int BGroup, int BIndex, double Score)? bestSwap = null;
|
||||
for (var firstGroup = 0; firstGroup < groups.Count; firstGroup++)
|
||||
for (var secondGroup = firstGroup + 1; secondGroup < groups.Count; secondGroup++)
|
||||
for (var firstIndex = 0; firstIndex < groups[firstGroup].Count; firstIndex++)
|
||||
for (var secondIndex = 0; secondIndex < groups[secondGroup].Count; secondIndex++)
|
||||
{
|
||||
(groups[firstGroup][firstIndex], groups[secondGroup][secondIndex]) =
|
||||
(groups[secondGroup][secondIndex], groups[firstGroup][firstIndex]);
|
||||
var score = AssignmentPenalty(groups);
|
||||
(groups[firstGroup][firstIndex], groups[secondGroup][secondIndex]) =
|
||||
(groups[secondGroup][secondIndex], groups[firstGroup][firstIndex]);
|
||||
if (score + 0.000001 < best && (bestSwap is null || score < bestSwap.Value.Score))
|
||||
bestSwap = (firstGroup, firstIndex, secondGroup, secondIndex, score);
|
||||
}
|
||||
if (bestSwap is { } swap)
|
||||
{
|
||||
(groups[swap.AGroup][swap.AIndex], groups[swap.BGroup][swap.BIndex]) =
|
||||
(groups[swap.BGroup][swap.BIndex], groups[swap.AGroup][swap.AIndex]);
|
||||
best = swap.Score;
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double AssignmentPenalty(List<List<Student>> groups)
|
||||
{
|
||||
var mean = _performance.Values.Where(x => x.HasValue).Select(x => x!.Value).DefaultIfEmpty(0.5).Average();
|
||||
var distributionWeight = Math.Abs(PerformanceDistribution) / 100.0;
|
||||
double performancePenalty;
|
||||
if (PerformanceDistribution < -20)
|
||||
{
|
||||
performancePenalty = groups.Sum(group =>
|
||||
{
|
||||
var values = group.Select(s => _performance.GetValueOrDefault(s.Id) ?? mean).ToList();
|
||||
var groupMean = values.Average();
|
||||
return values.Sum(value => Math.Pow(value - groupMean, 2));
|
||||
});
|
||||
}
|
||||
else if (PerformanceDistribution > 20)
|
||||
{
|
||||
var averages = groups.Select(group => group.Average(s => _performance.GetValueOrDefault(s.Id) ?? mean)).ToList();
|
||||
performancePenalty = averages.Sum(value => Math.Pow(value - averages.Average(), 2));
|
||||
}
|
||||
else performancePenalty = 0;
|
||||
|
||||
var supportPenalty = 0.0;
|
||||
if (DistributeSupportStudents && _supportStudentIds.Count > 0)
|
||||
{
|
||||
var counts = groups.Select(g => g.Count(s => _supportStudentIds.Contains(s.Id))).ToList();
|
||||
supportPenalty = counts.Sum(count => Math.Pow(count - counts.Average(), 2)) * 2.0;
|
||||
}
|
||||
|
||||
var groupByStudent = groups.SelectMany((group, index) => group.Select(s => (s.Id, index)))
|
||||
.ToDictionary(x => x.Id, x => x.index);
|
||||
var forcedPenalty = ForcedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: true)) *
|
||||
PairWeight(ForcedPairPriority);
|
||||
var excludedPenalty = ExcludedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: false)) *
|
||||
PairWeight(ExcludedPairPriority);
|
||||
return performancePenalty * distributionWeight + supportPenalty + forcedPenalty + excludedPenalty;
|
||||
}
|
||||
|
||||
private int ResolveGroupCount(int participantCount)
|
||||
{
|
||||
var maxGroups = Math.Max(1, participantCount / 2);
|
||||
var requestedCount = Math.Clamp(TargetGroupCount, 1, maxGroups);
|
||||
var sizeBasedCount = Math.Clamp((int)Math.Ceiling(participantCount / (double)Math.Max(2, TargetGroupSize)), 1, maxGroups);
|
||||
if (CountPriority >= 99 && SizePriority < 99) return requestedCount;
|
||||
if (SizePriority >= 99 && CountPriority < 99) return sizeBasedCount;
|
||||
|
||||
return Enumerable.Range(1, maxGroups).MinBy(count =>
|
||||
{
|
||||
var sizeDeviation = Math.Abs(participantCount / (double)count - TargetGroupSize) / Math.Max(2, TargetGroupSize);
|
||||
var countDeviation = Math.Abs(count - requestedCount) / (double)Math.Max(1, requestedCount);
|
||||
return sizeDeviation * (0.05 + SizePriority / 100.0) +
|
||||
countDeviation * (0.05 + CountPriority / 100.0);
|
||||
});
|
||||
}
|
||||
|
||||
private void InvalidateResult()
|
||||
{
|
||||
GeneratedGroups.Clear();
|
||||
HasGeneratedGroups = false;
|
||||
StatusText = _participants.Count >= 2
|
||||
? $"{_participants.Count} Schüler stehen zur Einteilung bereit."
|
||||
: "Für eine Gruppeneinteilung werden mindestens zwei Schüler benötigt.";
|
||||
UpdateWarning(null);
|
||||
}
|
||||
|
||||
private void UpdateWarning(int? resolvedCount, List<List<Student>>? assignments = null)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (_attendanceWarning.Length > 0) warnings.Add(_attendanceWarning);
|
||||
if (_participants.Count >= 2)
|
||||
{
|
||||
if (_participants.Count != Math.Max(2, TargetGroupSize) * TargetGroupCount)
|
||||
{
|
||||
var resolution = resolvedCount is null ? "" : $" Verwendet werden {resolvedCount} Gruppen.";
|
||||
warnings.Add($"Zielgröße {TargetGroupSize} und {TargetGroupCount} Gruppen sind bei {_participants.Count} Schülern nicht gleichzeitig exakt erfüllbar.{resolution}");
|
||||
}
|
||||
}
|
||||
if (assignments is not null)
|
||||
{
|
||||
var groupByStudent = assignments.SelectMany((group, index) => group.Select(s => (s.Id, index)))
|
||||
.ToDictionary(x => x.Id, x => x.index);
|
||||
var unavailable = ForcedPairs.Concat(ExcludedPairs).Count(rule =>
|
||||
!groupByStudent.ContainsKey(rule.FirstId) || !groupByStudent.ContainsKey(rule.SecondId));
|
||||
var forcedOpen = ForcedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: true));
|
||||
var excludedOpen = ExcludedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: false));
|
||||
if (unavailable > 0) warnings.Add($"{unavailable} Paarregel(n) sind wegen fehlender Teilnehmender heute nicht anwendbar.");
|
||||
if (forcedOpen > 0) warnings.Add($"{forcedOpen} gewünschte Paarung(en) konnten mit den aktuellen Prioritäten nicht erfüllt werden.");
|
||||
if (excludedOpen > 0) warnings.Add($"{excludedOpen} ausgeschlossene Paarung(en) konnten mit den aktuellen Prioritäten nicht getrennt werden.");
|
||||
}
|
||||
WarningText = string.Join(" ", warnings);
|
||||
}
|
||||
|
||||
private void AddPairRule(ObservableCollection<StudentPairRule> target,
|
||||
ObservableCollection<StudentPairRule> opposite)
|
||||
{
|
||||
PairingMessage = "";
|
||||
if (SelectedPairStudentA is null || SelectedPairStudentB is null)
|
||||
{
|
||||
PairingMessage = "Bitte zwei Schüler auswählen.";
|
||||
return;
|
||||
}
|
||||
if (SelectedPairStudentA.Id == SelectedPairStudentB.Id)
|
||||
{
|
||||
PairingMessage = "Bitte zwei verschiedene Schüler auswählen.";
|
||||
return;
|
||||
}
|
||||
if (target.Any(rule => rule.Matches(SelectedPairStudentA.Id, SelectedPairStudentB.Id)))
|
||||
{
|
||||
PairingMessage = "Diese Paarregel ist bereits vorhanden.";
|
||||
return;
|
||||
}
|
||||
var conflicting = opposite.FirstOrDefault(rule => rule.Matches(SelectedPairStudentA.Id, SelectedPairStudentB.Id));
|
||||
if (conflicting is not null) opposite.Remove(conflicting);
|
||||
StudentPairRule? created = null;
|
||||
created = new StudentPairRule(SelectedPairStudentA, SelectedPairStudentB, rule =>
|
||||
{
|
||||
target.Remove(rule);
|
||||
InvalidateResult();
|
||||
});
|
||||
target.Add(created);
|
||||
SelectedPairStudentA = null;
|
||||
SelectedPairStudentB = null;
|
||||
InvalidateResult();
|
||||
}
|
||||
|
||||
private static bool PairViolated(StudentPairRule rule, IReadOnlyDictionary<Guid, int> groups,
|
||||
bool mustBeTogether)
|
||||
{
|
||||
if (!groups.TryGetValue(rule.FirstId, out var first) || !groups.TryGetValue(rule.SecondId, out var second))
|
||||
return false;
|
||||
return mustBeTogether ? first != second : first == second;
|
||||
}
|
||||
|
||||
private static double PairWeight(double priority) => 0.05 + priority / 100.0 * 8.0;
|
||||
|
||||
private static string PriorityLabel(double value) => value switch
|
||||
{
|
||||
>= 90 => "nahezu fix",
|
||||
>= 65 => "hohe Priorität",
|
||||
>= 35 => "mittlere Priorität",
|
||||
>= 10 => "flexibel",
|
||||
_ => "nur Richtwert",
|
||||
};
|
||||
|
||||
private static double? ParseGradeScore(string value, GradingSystem system)
|
||||
{
|
||||
var text = value.Trim();
|
||||
var tendency = 0.0;
|
||||
if (text.EndsWith('+')) { tendency = system == GradingSystem.Grades1To6 ? -1.0 / 3 : 1.0 / 3; text = text[..^1]; }
|
||||
else if (text.EndsWith('-')) { tendency = system == GradingSystem.Grades1To6 ? 1.0 / 3 : -1.0 / 3; text = text[..^1]; }
|
||||
if (!double.TryParse(text, System.Globalization.NumberStyles.Number,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return null;
|
||||
parsed += tendency;
|
||||
return system == GradingSystem.Grades1To6
|
||||
? Math.Clamp((6 - parsed) / 5.0, 0, 1)
|
||||
: Math.Clamp(parsed / 15.0, 0, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<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.StudentGroupGeneratorWindow"
|
||||
x:DataType="vm:StudentGroupGeneratorViewModel"
|
||||
Title="Gruppen zusammenstellen"
|
||||
Width="780" Height="650" MinWidth="620" MinHeight="480"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
|
||||
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,16">
|
||||
<TextBlock Text="Gruppen zusammenstellen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="Eigenständiges Fenster für die Bildschirmfreigabe"
|
||||
FontSize="12" Opacity="0.55"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<Grid x:Name="GroupResults">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.18"/>
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
|
||||
IsVisible="{Binding !HasGeneratedGroups}">
|
||||
<TextBlock Text="◇ ◇ ◇" FontSize="26" Opacity="0.35" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Noch keine Gruppen erstellt" FontSize="20" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StatusText}" Opacity="0.6" TextAlignment="Center"
|
||||
TextWrapping="Wrap" MaxWidth="460"/>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding GeneratedGroups}"
|
||||
IsVisible="{Binding HasGeneratedGroups}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" HorizontalAlignment="Center"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GeneratedStudentGroup">
|
||||
<Border Width="220" Margin="7" Padding="16"
|
||||
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AppCardBorderBrush}"
|
||||
BorderThickness="1" CornerRadius="12">
|
||||
<StackPanel Spacing="9">
|
||||
<TextBlock Text="{Binding Title}" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
||||
<ItemsControl ItemsSource="{Binding Members}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentGroupMember">
|
||||
<TextBlock Text="{Binding Name}" Margin="0,3" TextWrapping="Wrap"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,10">
|
||||
<Button Content="Gruppen zusammenstellen" Click="OnGenerate"
|
||||
IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center" Height="48" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center" Opacity="0.65"
|
||||
IsVisible="{Binding HasGeneratedGroups}"/>
|
||||
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="10,7"
|
||||
IsVisible="{Binding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding WarningText}" TextWrapping="Wrap" FontSize="12"
|
||||
Foreground="{DynamicResource AppStatusWarningBrush}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Expander Grid.Row="3" Header="Optionen" IsExpanded="False">
|
||||
<ScrollViewer MaxHeight="340">
|
||||
<StackPanel Spacing="11" Margin="0,10,6,4">
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Grid.Column="0" Spacing="5">
|
||||
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentDrawGroup">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="5">
|
||||
<TextBlock Text="Teilnehmende" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding ParticipantModeOptions}"
|
||||
SelectedItem="{Binding ParticipantModeName}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,16,*" Margin="0,5,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="5">
|
||||
<TextBlock Text="Zielgröße je Gruppe" FontSize="12" Opacity="0.7"/>
|
||||
<NumericUpDown Value="{Binding TargetGroupSize}" Minimum="2" Maximum="20"
|
||||
Increment="1" FormatString="0"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="5">
|
||||
<TextBlock Text="Zielanzahl Gruppen" FontSize="12" Opacity="0.7"/>
|
||||
<NumericUpDown Value="{Binding TargetGroupCount}" Minimum="1" Maximum="20"
|
||||
Increment="1" FormatString="0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="160,*,110" RowDefinitions="Auto,Auto,Auto" Margin="0,6,0,0">
|
||||
<TextBlock Grid.Row="0" Text="Priorität Zielgröße" VerticalAlignment="Center"/>
|
||||
<Slider Grid.Row="0" Grid.Column="1" Minimum="0" Maximum="100"
|
||||
Value="{Binding SizePriority}" Margin="10,0"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding SizePriorityLabel}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Priorität Gruppenanzahl" VerticalAlignment="Center"/>
|
||||
<Slider Grid.Row="1" Grid.Column="1" Minimum="0" Maximum="100"
|
||||
Value="{Binding CountPriority}" Margin="10,0"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding CountPriorityLabel}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="Leistungsverteilung" VerticalAlignment="Center"/>
|
||||
<Slider Grid.Row="2" Grid.Column="1" Minimum="-100" Maximum="100"
|
||||
Value="{Binding PerformanceDistribution}" Margin="10,0"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Text="{Binding PerformanceDistributionLabel}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,*" Margin="170,-7,110,0">
|
||||
<TextBlock Text="homogen" FontSize="10" Opacity="0.5"/>
|
||||
<TextBlock Grid.Column="1" Text="heterogen" HorizontalAlignment="Right" FontSize="10" Opacity="0.5"/>
|
||||
</Grid>
|
||||
|
||||
<CheckBox Content="Förderschüler möglichst gleichmäßig verteilen"
|
||||
IsChecked="{Binding DistributeSupportStudents}" Margin="0,5,0,0"/>
|
||||
|
||||
<Border Height="1" Background="{DynamicResource AppCardBorderBrush}" Margin="0,5"/>
|
||||
<TextBlock Text="Paarregeln" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Zwei Schüler auswählen und anschließend festlegen, ob sie zusammen oder getrennt arbeiten sollen."
|
||||
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,12,*" Margin="0,2,0,0">
|
||||
<ComboBox Grid.Column="0" ItemsSource="{Binding PairCandidates}"
|
||||
SelectedItem="{Binding SelectedPairStudentA}" PlaceholderText="Erster Schüler">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentPairCandidate">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding PairCandidates}"
|
||||
SelectedItem="{Binding SelectedPairStudentB}" PlaceholderText="Zweiter Schüler">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentPairCandidate">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<Button Grid.Column="0" Content="Zusammen einteilen" Command="{Binding AddForcedPairCommand}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<Button Grid.Column="2" Content="Voneinander trennen" Command="{Binding AddExcludedPairCommand}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding PairingMessage}" Foreground="{DynamicResource AppStatusWarningBrush}"
|
||||
FontSize="11" IsVisible="{Binding PairingMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<Grid ColumnDefinitions="160,*,110" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Text="Priorität zusammen" VerticalAlignment="Center"/>
|
||||
<Slider Grid.Row="0" Grid.Column="1" Minimum="0" Maximum="100"
|
||||
Value="{Binding ForcedPairPriority}" Margin="10,0"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding ForcedPairPriorityLabel}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
|
||||
<TextBlock Grid.Row="1" Text="Priorität trennen" VerticalAlignment="Center"/>
|
||||
<Slider Grid.Row="1" Grid.Column="1" Minimum="0" Maximum="100"
|
||||
Value="{Binding ExcludedPairPriority}" Margin="10,0"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding ExcludedPairPriorityLabel}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="12" Opacity="0.7"/>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0" Spacing="5">
|
||||
<TextBlock Text="Zusammen" FontSize="12" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding ForcedPairs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentPairRule">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||
<TextBlock Text="{Binding Display}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="×" Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="Regel entfernen" Padding="8,2"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="5">
|
||||
<TextBlock Text="Getrennt" FontSize="12" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding ExcludedPairs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:StudentPairRule">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||
<TextBlock Text="{Binding Display}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="×" Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="Regel entfernen" Padding="8,2"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Expander>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class StudentGroupGeneratorWindow : Window
|
||||
{
|
||||
private bool _isAnimating;
|
||||
|
||||
public StudentGroupGeneratorWindow() => InitializeComponent();
|
||||
|
||||
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isAnimating || DataContext is not StudentGroupGeneratorViewModel vm ||
|
||||
!vm.GenerateCommand.CanExecute(null)) return;
|
||||
_isAnimating = true;
|
||||
var results = this.FindControl<Grid>("GroupResults");
|
||||
if (results is not null) results.Opacity = 0.15;
|
||||
await Task.Delay(140);
|
||||
vm.GenerateCommand.Execute(null);
|
||||
if (results is not null) results.Opacity = 1;
|
||||
_isAnimating = false;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@
|
||||
</MenuItem>
|
||||
<MenuItem Header="Unterricht">
|
||||
<MenuItem Header="Schüler ziehen…" Click="OnOpenRandomStudentPicker"/>
|
||||
<MenuItem Header="Gruppen zusammenstellen…" Click="OnOpenStudentGroupGenerator"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<DrawerPage x:Name="RootDrawer"
|
||||
|
||||
@@ -23,6 +23,7 @@ public partial class MainWindow : Window
|
||||
private bool _finalSyncStarted;
|
||||
private bool _closeAfterFinalSync;
|
||||
private RandomStudentPickerWindow? _randomStudentPickerWindow;
|
||||
private StudentGroupGeneratorWindow? _studentGroupGeneratorWindow;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -93,6 +94,28 @@ public partial class MainWindow : Window
|
||||
_randomStudentPickerWindow.Show();
|
||||
}
|
||||
|
||||
private void OnOpenStudentGroupGenerator(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (_studentGroupGeneratorWindow is not null)
|
||||
{
|
||||
_studentGroupGeneratorWindow.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var currentGroupId = (DataContext as MainWindowViewModel)?.CurrentGroupDetail?.Group?.Id;
|
||||
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>(),
|
||||
currentGroupId);
|
||||
_studentGroupGeneratorWindow = new StudentGroupGeneratorWindow { DataContext = vm };
|
||||
_studentGroupGeneratorWindow.Closed += (_, _) => _studentGroupGeneratorWindow = null;
|
||||
_studentGroupGeneratorWindow.Show();
|
||||
}
|
||||
|
||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm) return;
|
||||
|
||||
@@ -3691,6 +3691,20 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
|
||||
Wiederholungen werden vermieden. Die Ziehhistorie bleibt rein im Arbeitsspeicher und wird
|
||||
beim Schließen oder Zurücksetzen verworfen.
|
||||
|
||||
- [x] **7.1.7** Eigenständiges Fenster „Gruppen zusammenstellen“ im Menü Unterricht. Es erstellt
|
||||
Gruppen wahlweise für die Zukunft aus allen aktiven Mitgliedern oder ad hoc unter
|
||||
Berücksichtigung der heute erfassten Anwesenheit. Zielgröße und Zielanzahl sind gemeinsam
|
||||
vorgebbar; je ein Prioritätsregler entscheidet bei mathematisch unvereinbaren Vorgaben,
|
||||
welches Ziel stärker gewichtet wird. Der Konflikt und die tatsächlich verwendete Aufteilung
|
||||
werden sichtbar erklärt. Ein weiterer Regler reicht von homogenen bis heterogenen
|
||||
Leistungsgruppen und nutzt vorhandene Noten/Mitarbeitsbewertungen, ohne diese Werte im
|
||||
teilbaren Fenster anzuzeigen. Die Optionen sind standardmäßig eingeklappt.
|
||||
**Nachtrag:** Förderschüler (`GroupMembership.Niveau = Foerder`) können möglichst
|
||||
gleichmäßig verteilt werden. Frei definierbare Schülerpaare lassen sich entweder bevorzugt
|
||||
zusammen einteilen oder voneinander trennen; beide Regelarten besitzen einen eigenen
|
||||
Prioritätsregler und melden nach der Berechnung transparent, falls eine Regel nicht
|
||||
anwendbar oder im Zusammenspiel mit den übrigen Zielen nicht erfüllbar war.
|
||||
|
||||
### 7.2 Gruppen
|
||||
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
|
||||
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,
|
||||
|
||||
Reference in new issue
Block a user