This commit is contained in:
2026-08-19 14:29:06 +02:00
21 changed files with 604 additions and 58 deletions
+5
View File
@@ -60,6 +60,11 @@ public enum AttendanceStatus
OtherSchoolEvent, OtherSchoolEvent,
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben. // Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
Present, Present,
Late,
SignificantlyLate,
LeftDuringClass,
LearningIsland,
Suspended,
} }
/// <summary> /// <summary>
+10
View File
@@ -12,6 +12,16 @@ public class SeatingPlan
public string Room { get; set; } = ""; public string Room { get; set; } = "";
public int Rows { get; set; } = 4; public int Rows { get; set; } = 4;
public int Columns { get; set; } = 4; public int Columns { get; set; } = 4;
/// <summary>
/// Zeigt die Tafel unterhalb des Sitzrasters an. Der Standardwert <c>false</c>
/// erhält für bestehende Pläne die bisherige Darstellung oberhalb des Rasters.
/// </summary>
public bool IsBoardAtBottom { get; set; }
/// <summary>
/// Zusätzlicher horizontaler Abstand nach jeder Spalte, in Pixeln. Der Eintrag mit Index 0
/// liegt zwischen der ersten und zweiten Spalte. Fehlende Einträge bedeuten keinen Abstand.
/// </summary>
public List<double> ColumnGapWidths { get; set; } = [];
public List<SeatAssignment> Assignments { get; set; } = []; public List<SeatAssignment> Assignments { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -25,10 +25,13 @@ public class AttendanceBalanceService
.Select(e => e.Status!.Value) .Select(e => e.Status!.Value)
.ToList(); .ToList();
var present = relevant.Count(s => s == AttendanceStatus.Present); var present = relevant.Count(s => s is AttendanceStatus.Present
or AttendanceStatus.Late or AttendanceStatus.SignificantlyLate);
var excused = relevant.Count(s => s == AttendanceStatus.Excused); var excused = relevant.Count(s => s == AttendanceStatus.Excused);
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused or AttendanceStatus.Truant); var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused
var schoolEvent = relevant.Count(s => s == AttendanceStatus.OtherSchoolEvent); or AttendanceStatus.Truant or AttendanceStatus.LeftDuringClass);
var schoolEvent = relevant.Count(s => s is AttendanceStatus.OtherSchoolEvent
or AttendanceStatus.LearningIsland or AttendanceStatus.Suspended);
var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending); var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
var total = relevant.Count; var total = relevant.Count;
+5 -1
View File
@@ -267,6 +267,8 @@ public sealed class RepositoryTests
repo.Save(new SeatingPlan repo.Save(new SeatingPlan
{ {
GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3, GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3,
IsBoardAtBottom = true,
ColumnGapWidths = [0, 90],
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }], Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }],
}); });
@@ -274,7 +276,9 @@ public sealed class RepositoryTests
Assert.Equal(2, plans.Count); Assert.Equal(2, plans.Count);
Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id); Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id);
Assert.Contains(plans, p => p.Room == "Physik" && p.Assignments.Single().StudentId == ben.Id); Assert.Contains(plans, p => p.Room == "Physik" && p.IsBoardAtBottom
&& p.ColumnGapWidths.SequenceEqual([0, 90])
&& p.Assignments.Single().StudentId == ben.Id);
} }
[Fact] [Fact]
@@ -107,6 +107,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
plan.Name = plan.Name?.Trim() ?? ""; plan.Name = plan.Name?.Trim() ?? "";
plan.Room = plan.Room?.Trim() ?? ""; plan.Room = plan.Room?.Trim() ?? "";
plan.Assignments ??= []; plan.Assignments ??= [];
plan.ColumnGapWidths ??= [];
if (plan.Name.Length == 0) if (plan.Name.Length == 0)
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein."); throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10) if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
@@ -114,6 +115,12 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
if (db.Groups.FindById(plan.GroupId) is null) if (db.Groups.FindById(plan.GroupId) is null)
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht."); throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
plan.ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1))
.Select(i => i < plan.ColumnGapWidths.Count && double.IsFinite(plan.ColumnGapWidths[i])
? Math.Clamp(plan.ColumnGapWidths[i], 0, 300)
: 0)
.ToList();
var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId) var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId)
.FirstOrDefault(p => p.Id != plan.Id .FirstOrDefault(p => p.Id != plan.Id
&& string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase) && string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)
@@ -0,0 +1,62 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ParticipationSessionEditingTests
{
[Fact]
public void Bearbeitungsdialog_AendertBestehendeSessionOhneVerknuepfungenZuErsetzen()
{
var session = new ParticipationSession
{
GroupId = Guid.NewGuid(),
Date = new DateOnly(2026, 8, 19),
Comment = "Sitzplan",
LessonId = Guid.NewGuid(),
CompetencyCodes = ["K1"],
};
var vm = new AddSessionDialogViewModel(session)
{
DateText = "20.08.2026",
Comment = "Elektrische Stromkreise",
};
vm.SaveCommand.Execute(null);
Assert.Same(session, vm.Result);
Assert.Equal(new DateOnly(2026, 8, 20), session.Date);
Assert.Equal("Elektrische Stromkreise", session.Comment);
Assert.NotNull(session.LessonId);
Assert.Equal(["K1"], session.CompetencyCodes);
}
[Fact]
public async Task BearbeitenBefehl_SpeichertSessionUndAktualisiertAnzeige()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
var session = new ParticipationSession
{
GroupId = group.Id,
Date = new DateOnly(2026, 8, 19),
Comment = "Sitzplan",
};
var sessions = new FakeSessions([session]);
var vm = new ParticipationTabViewModel(
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
vm.OnEditSession = existing =>
{
existing.Comment = "Nachbesprechung Elektrizität";
return Task.FromResult<ParticipationSession?>(existing);
};
vm.Initialize(group.Id, group.SchoolYear);
await vm.EditSessionCommand.ExecuteAsync(null);
Assert.Equal(session.Id, vm.SelectedSession?.Id);
Assert.Equal("Nachbesprechung Elektrizität", sessions.GetById(session.Id)?.Comment);
Assert.Contains("Nachbesprechung Elektrizität", vm.SelectedSessionDisplay);
}
}
@@ -6,6 +6,24 @@ namespace LehrerApp.Desktop.Tests;
public sealed class QuickInputViewModelTests public sealed class QuickInputViewModelTests
{ {
[Theory]
[InlineData(AttendanceStatus.Late, false, "Verspätet", "V")]
[InlineData(AttendanceStatus.SignificantlyLate, false, "Erheblich verspätet", "V!")]
[InlineData(AttendanceStatus.LeftDuringClass, true, "Während des Unterrichts abgängig", "A")]
[InlineData(AttendanceStatus.LearningIsland, true, "Lerninsel", "L")]
[InlineData(AttendanceStatus.Suspended, true, "Suspendiert", "S")]
public void ZusaetzlicheAnwesenheitsstatus_HabenAnzeigeUndPassendeAbwesenheitswertung(
AttendanceStatus status, bool isAbsent, string label, string symbol)
{
var row = new ParticipationStudentRow(Guid.NewGuid(), "Anna",
new ParticipationEntry { Attendance = status }, [], []);
Assert.Equal(isAbsent, row.IsAbsent);
Assert.Equal(label, row.AttendanceTooltip);
Assert.Equal(symbol, row.AttendanceLabel);
Assert.False(string.IsNullOrWhiteSpace(AttendanceDisplay.Color(status)));
}
[Fact] [Fact]
public void ZurueckZuVorherigemSchueler_ZeigtBereitsGesetzteBewertung() public void ZurueckZuVorherigemSchueler_ZeigtBereitsGesetzteBewertung()
{ {
@@ -1,11 +1,41 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Groups;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
public sealed class SeatingPlanViewModelTests public sealed class SeatingPlanViewModelTests
{ {
[Fact]
public void TafelpositionWirdAusPlanGeladenUndBeimBearbeitenGespeichert()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan
{
GroupId = groupId,
Name = "Standard",
Rows = 1,
Columns = 1,
IsBoardAtBottom = true,
};
var plans = new FakeSeatingPlans([plan]);
var tabVm = new SeatingPlanTabViewModel(
plans, new FakeStudents([]), new FakeMemberships([]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
tabVm.Initialize(groupId, isReadOnly: false);
Assert.True(tabVm.IsBoardAtBottom);
Assert.False(tabVm.IsBoardAtTop);
var dialogVm = tabVm.CreateDialogViewModel(plan);
dialogVm.IsBoardAtBottom = false;
dialogVm.SaveCommand.Execute(null);
Assert.False(plans.GetById(plan.Id)!.IsBoardAtBottom);
}
[Fact] [Fact]
public void AuswahlEinesBereitsZugeordnetenSchuelers_VerschiebtIhnAufDenNeuenPlatz() public void AuswahlEinesBereitsZugeordnetenSchuelers_VerschiebtIhnAufDenNeuenPlatz()
{ {
@@ -27,6 +57,7 @@ public sealed class SeatingPlanViewModelTests
var vm = new SeatingPlanTabViewModel(plans, students, memberships, var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()); new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false); vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id); vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id);
@@ -75,6 +106,7 @@ public sealed class SeatingPlanViewModelTests
var vm = new SeatingPlanTabViewModel(plans, students, memberships, var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()); new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false); vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.MoveSeat(vm.Seats[0], vm.Seats[1]); vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
@@ -83,6 +115,78 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal(2, plans.GetById(plan.Id)!.Assignments.Count); Assert.Equal(2, plans.GetById(plan.Id)!.Assignments.Count);
} }
[Fact]
public void Ansichtsmodus_VerhindertVerschiebenUndBlendetBearbeitungsbefehleAus()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
Assert.False(vm.IsEditMode);
Assert.False(vm.CanEditLayout);
Assert.False(vm.EditPlanCommand.CanExecute(null));
Assert.Equal(student.Id, vm.Seats[0].SelectedOption.StudentId);
Assert.Null(vm.Seats[1].SelectedOption.StudentId);
vm.IsEditMode = true;
Assert.True(vm.CanEditLayout);
Assert.True(vm.EditPlanCommand.CanExecute(null));
Assert.All(vm.Seats, seat => Assert.True(seat.CanEdit));
}
[Fact]
public void Tischabstaende_WerdenBeimBearbeitenProSpaltengrenzeGespeichert()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 2, Columns = 4,
ColumnGapWidths = [0, 80, 20],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanDialogViewModel(plans, groupId, plan);
Assert.Equal([0m, 80m, 20m], vm.ColumnGaps.Select(g => g.Width));
vm.ColumnGaps[0].Width = 40;
vm.SaveCommand.Execute(null);
Assert.Equal([40d, 80d, 20d], plans.GetById(plan.Id)!.ColumnGapWidths);
}
[Fact]
public void SitzplanPanel_FuegtAbstandNurAnKonfigurierterSpaltengrenzeEin()
{
var panel = new SeatingPlanPanel
{
Columns = 3,
ColumnGapWidths = [0, 70],
};
for (var i = 0; i < 6; i++)
panel.Children.Add(new Avalonia.Controls.Border { Width = 100, Height = 50 });
panel.Measure(Avalonia.Size.Infinity);
panel.Arrange(new Avalonia.Rect(panel.DesiredSize));
Assert.Equal(370, panel.DesiredSize.Width);
Assert.Equal(100, panel.DesiredSize.Height);
Assert.Equal(0, panel.Children[0].Bounds.X);
Assert.Equal(100, panel.Children[1].Bounds.X);
Assert.Equal(270, panel.Children[2].Bounds.X);
Assert.Equal(270, panel.Children[5].Bounds.X);
}
[Fact] [Fact]
public void SitzplatzBewertung_ErstelltHeutigeSitzungUndSpeichertAlleDreiBereiche() public void SitzplatzBewertung_ErstelltHeutigeSitzungUndSpeichertAlleDreiBereiche()
{ {
@@ -48,6 +48,7 @@ public partial class ParticipationTabViewModel : ObservableObject
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = []; public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
public Func<Task<ParticipationSession?>>? OnAddSession { get; set; } public Func<Task<ParticipationSession?>>? OnAddSession { get; set; }
public Func<ParticipationSession, Task<ParticipationSession?>>? OnEditSession { get; set; }
public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; } public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; }
public Func<ParticipationTabViewModel, Task>? OnStatusQuickInput { get; set; } public Func<ParticipationTabViewModel, Task>? OnStatusQuickInput { get; set; }
public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; } public Func<ParticipationTabViewModel, Task>? OnComputeGrade { get; set; }
@@ -118,6 +119,7 @@ public partial class ParticipationTabViewModel : ObservableObject
partial void OnSelectedSessionChanged(ParticipationSessionItem? value) partial void OnSelectedSessionChanged(ParticipationSessionItem? value)
{ {
OnPropertyChanged(nameof(SelectedSessionDisplay)); OnPropertyChanged(nameof(SelectedSessionDisplay));
EditSessionCommand.NotifyCanExecuteChanged();
if (value is null) if (value is null)
{ {
StudentRows.Clear(); StudentRows.Clear();
@@ -265,6 +267,23 @@ public partial class ParticipationTabViewModel : ObservableObject
SelectedSession = Sessions.FirstOrDefault(s => s.Id == session.Id); SelectedSession = Sessions.FirstOrDefault(s => s.Id == session.Id);
} }
[RelayCommand(CanExecute = nameof(CanEditSession))]
private async Task EditSession()
{
if (SelectedSession is null || OnEditSession is null) return;
var session = _sessions.GetById(SelectedSession.Id);
if (session is null) return;
var edited = await OnEditSession(session);
if (edited is null) return;
edited.GroupId = _groupId;
_sessions.Save(edited);
LoadSessions();
}
private bool CanEditSession() => !IsReadOnly && SelectedSession is not null;
partial void OnIsReadOnlyChanged(bool value) => EditSessionCommand.NotifyCanExecuteChanged();
[RelayCommand(CanExecute = nameof(CanQuickInput))] [RelayCommand(CanExecute = nameof(CanQuickInput))]
private async Task QuickInput() private async Task QuickInput()
{ {
@@ -363,7 +382,10 @@ public partial class ParticipationStudentRow : ObservableObject
public string AttendanceTooltip => AttendanceDisplay.Label(Attendance); public string AttendanceTooltip => AttendanceDisplay.Label(Attendance);
/// Abwesend im Sinne der Mitarbeitsbewertung: eine Bewertung ergibt für diese Stunde keinen /// Abwesend im Sinne der Mitarbeitsbewertung: eine Bewertung ergibt für diese Stunde keinen
/// Sinn, unabhängig davon, ob die Abwesenheit entschuldigt ist oder noch geklärt werden muss. /// Sinn, unabhängig davon, ob die Abwesenheit entschuldigt ist oder noch geklärt werden muss.
public bool IsAbsent => Attendance is not null and not AttendanceStatus.Present; public bool IsAbsent => Attendance is not null
and not AttendanceStatus.Present
and not AttendanceStatus.Late
and not AttendanceStatus.SignificantlyLate;
public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework); public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework);
public string HomeworkTooltip => HomeworkDisplay.Label(Homework); public string HomeworkTooltip => HomeworkDisplay.Label(Homework);
@@ -538,6 +560,11 @@ public static class AttendanceDisplay
AttendanceStatus.Unexcused => "Krank, unentschuldigt", AttendanceStatus.Unexcused => "Krank, unentschuldigt",
AttendanceStatus.Truant => "Geschwänzt", AttendanceStatus.Truant => "Geschwänzt",
AttendanceStatus.OtherSchoolEvent => "Andere Schulveranstaltung", AttendanceStatus.OtherSchoolEvent => "Andere Schulveranstaltung",
AttendanceStatus.Late => "Verspätet",
AttendanceStatus.SignificantlyLate => "Erheblich verspätet",
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
AttendanceStatus.LearningIsland => "Lerninsel",
AttendanceStatus.Suspended => "Suspendiert",
_ => "Anwesend", _ => "Anwesend",
}; };
@@ -550,6 +577,11 @@ public static class AttendanceDisplay
AttendanceStatus.Unexcused => "!", AttendanceStatus.Unexcused => "!",
AttendanceStatus.Truant => "✕", AttendanceStatus.Truant => "✕",
AttendanceStatus.OtherSchoolEvent => "◇", AttendanceStatus.OtherSchoolEvent => "◇",
AttendanceStatus.Late => "V",
AttendanceStatus.SignificantlyLate => "V!",
AttendanceStatus.LeftDuringClass => "A",
AttendanceStatus.LearningIsland => "L",
AttendanceStatus.Suspended => "S",
_ => "", _ => "",
}; };
@@ -561,6 +593,11 @@ public static class AttendanceDisplay
AttendanceStatus.Unexcused => "#D96C00", AttendanceStatus.Unexcused => "#D96C00",
AttendanceStatus.Truant => "#D64545", AttendanceStatus.Truant => "#D64545",
AttendanceStatus.OtherSchoolEvent => "#5277C3", AttendanceStatus.OtherSchoolEvent => "#5277C3",
AttendanceStatus.Late => "#D98200",
AttendanceStatus.SignificantlyLate => "#D96C00",
AttendanceStatus.LeftDuringClass => "#D64545",
AttendanceStatus.LearningIsland => "#5277C3",
AttendanceStatus.Suspended => "#6B6576",
_ => "", _ => "",
}; };
} }
@@ -698,12 +735,26 @@ public class ParticipationSessionItem
public partial class AddSessionDialogViewModel : ObservableObject public partial class AddSessionDialogViewModel : ObservableObject
{ {
private readonly ParticipationSession? _editingSession;
[ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today); [ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _comment = ""; [ObservableProperty] private string _comment = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _dateTextError = ""; [ObservableProperty] private string _dateTextError = "";
public ParticipationSession? Result { get; private set; } public ParticipationSession? Result { get; private set; }
public bool IsEditing => _editingSession is not null;
public string DialogTitle => IsEditing ? "Bewertungszeitpunkt bearbeiten" : "Bewertungszeitpunkt anlegen";
public string SaveButtonText => IsEditing ? "Speichern" : "Anlegen";
public AddSessionDialogViewModel(ParticipationSession? editingSession = null)
{
_editingSession = editingSession;
if (editingSession is null) return;
Date = editingSession.Date;
DateText = editingSession.Date.ToString("dd.MM.yyyy");
Comment = editingSession.Comment ?? "";
}
[RelayCommand] [RelayCommand]
private void Save() private void Save()
@@ -715,7 +766,10 @@ public partial class AddSessionDialogViewModel : ObservableObject
return; return;
} }
DateTextError = ""; DateTextError = "";
Result = new ParticipationSession { Date = date, Comment = Comment.Trim() }; var session = _editingSession ?? new ParticipationSession();
session.Date = date;
session.Comment = Comment.Trim();
Result = session;
} }
} }
@@ -24,6 +24,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject
[ObservableProperty] private string _planTitle = ""; [ObservableProperty] private string _planTitle = "";
[ObservableProperty] private string _planSubtitle = ""; [ObservableProperty] private string _planSubtitle = "";
[ObservableProperty] private string _assignmentSummary = ""; [ObservableProperty] private string _assignmentSummary = "";
[ObservableProperty] private bool _isBoardAtTop = true;
[ObservableProperty] private bool _isBoardAtBottom;
[ObservableProperty] private IReadOnlyList<double> _columnGapWidths = [];
[ObservableProperty] private bool _isEditMode;
public ObservableCollection<SeatingPlanSummary> Plans { get; } = []; public ObservableCollection<SeatingPlanSummary> Plans { get; } = [];
public ObservableCollection<SeatCellViewModel> Seats { get; } = []; public ObservableCollection<SeatCellViewModel> Seats { get; } = [];
@@ -33,6 +37,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public bool HasPlans => Plans.Count > 0; public bool HasPlans => Plans.Count > 0;
public bool HasSelectedPlan => _currentPlan is not null; public bool HasSelectedPlan => _currentPlan is not null;
public bool IsEditable => !_isReadOnly; public bool IsEditable => !_isReadOnly;
public bool CanEditLayout => IsEditable && IsEditMode;
public Func<SeatingPlan?, Task<SeatingPlan?>>? OnEditPlan { get; set; } public Func<SeatingPlan?, Task<SeatingPlan?>>? OnEditPlan { get; set; }
public Func<SeatingPlanSummary, Task<bool>>? OnConfirmDelete { get; set; } public Func<SeatingPlanSummary, Task<bool>>? OnConfirmDelete { get; set; }
public Func<SeatAssessmentViewModel, Task>? OnAssessStudent { get; set; } public Func<SeatAssessmentViewModel, Task>? OnAssessStudent { get; set; }
@@ -57,6 +62,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
{ {
_groupId = groupId; _groupId = groupId;
_isReadOnly = isReadOnly; _isReadOnly = isReadOnly;
IsEditMode = false;
LoadStudentOptions(); LoadStudentOptions();
ReloadPlans(); ReloadPlans();
OnPropertyChanged(nameof(IsEditable)); OnPropertyChanged(nameof(IsEditable));
@@ -102,6 +108,9 @@ public partial class SeatingPlanTabViewModel : ObservableObject
PlanTitle = ""; PlanTitle = "";
PlanSubtitle = ""; PlanSubtitle = "";
AssignmentSummary = ""; AssignmentSummary = "";
IsBoardAtTop = true;
IsBoardAtBottom = false;
ColumnGapWidths = [];
} }
else else
{ {
@@ -110,6 +119,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
PlanSubtitle = string.IsNullOrWhiteSpace(plan.Room) PlanSubtitle = string.IsNullOrWhiteSpace(plan.Room)
? $"{plan.Rows} × {plan.Columns} Plätze" ? $"{plan.Rows} × {plan.Columns} Plätze"
: $"Raum {plan.Room} · {plan.Rows} × {plan.Columns} Plätze"; : $"Raum {plan.Room} · {plan.Rows} × {plan.Columns} Plätze";
IsBoardAtBottom = plan.IsBoardAtBottom;
IsBoardAtTop = !plan.IsBoardAtBottom;
var savedGapWidths = plan.ColumnGapWidths ?? [];
ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1))
.Select(i => i < savedGapWidths.Count ? savedGapWidths[i] : 0)
.ToArray();
for (var row = 0; row < plan.Rows; row++) for (var row = 0; row < plan.Rows; row++)
for (var column = 0; column < plan.Columns; column++) for (var column = 0; column < plan.Columns; column++)
{ {
@@ -118,7 +133,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
? StudentSeatOption.Empty ? StudentSeatOption.Empty
: StudentOptions.FirstOrDefault(o => o.StudentId == assignment.StudentId) : StudentOptions.FirstOrDefault(o => o.StudentId == assignment.StudentId)
?? StudentSeatOption.Empty; ?? StudentSeatOption.Empty;
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, IsEditable)); Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, CanEditLayout));
} }
UpdateAssignmentSummary(); UpdateAssignmentSummary();
} }
@@ -128,7 +143,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private void OnSeatChanged(SeatCellViewModel changed) private void OnSeatChanged(SeatCellViewModel changed)
{ {
if (_currentPlan is null || !IsEditable) return; if (_currentPlan is null || !CanEditLayout) return;
if (changed.SelectedOption.StudentId is Guid studentId) if (changed.SelectedOption.StudentId is Guid studentId)
{ {
foreach (var other in Seats.Where(s => s != changed && s.SelectedOption.StudentId == studentId)) foreach (var other in Seats.Where(s => s != changed && s.SelectedOption.StudentId == studentId))
@@ -152,7 +167,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public void MoveSeat(SeatCellViewModel source, SeatCellViewModel target) public void MoveSeat(SeatCellViewModel source, SeatCellViewModel target)
{ {
if (!IsEditable || source == target || !source.SelectedOption.StudentId.HasValue) return; if (!CanEditLayout || source == target || !source.SelectedOption.StudentId.HasValue) return;
var targetOption = target.SelectedOption; var targetOption = target.SelectedOption;
target.SetSelectionSilently(source.SelectedOption); target.SetSelectionSilently(source.SelectedOption);
source.SetSelectionSilently(targetOption); source.SetSelectionSilently(targetOption);
@@ -161,7 +176,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public void AssignStudent(StudentSeatOption student, SeatCellViewModel target) public void AssignStudent(StudentSeatOption student, SeatCellViewModel target)
{ {
if (!IsEditable || !student.StudentId.HasValue) return; if (!CanEditLayout || !student.StudentId.HasValue) return;
foreach (var other in Seats.Where(s => s != target && s.SelectedOption.StudentId == student.StudentId)) foreach (var other in Seats.Where(s => s != target && s.SelectedOption.StudentId == student.StudentId))
other.SetSelectionSilently(StudentSeatOption.Empty); other.SetSelectionSilently(StudentSeatOption.Empty);
target.SetSelectionSilently(student); target.SetSelectionSilently(student);
@@ -170,7 +185,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public void ClearSeat(SeatCellViewModel seat) public void ClearSeat(SeatCellViewModel seat)
{ {
if (!IsEditable || !seat.SelectedOption.StudentId.HasValue) return; if (!CanEditLayout || !seat.SelectedOption.StudentId.HasValue) return;
seat.SetSelectionSilently(StudentSeatOption.Empty); seat.SetSelectionSilently(StudentSeatOption.Empty);
SaveSeatAssignments(); SaveSeatAssignments();
} }
@@ -223,8 +238,15 @@ public partial class SeatingPlanTabViewModel : ObservableObject
ReloadPlans(); ReloadPlans();
} }
private bool CanEdit() => IsEditable; partial void OnIsEditModeChanged(bool value)
private bool CanEditSelected() => IsEditable && _currentPlan is not null; {
OnPropertyChanged(nameof(CanEditLayout));
foreach (var seat in Seats) seat.CanEdit = CanEditLayout;
NotifyCommands();
}
private bool CanEdit() => IsEditable && (IsEditMode || !HasPlans);
private bool CanEditSelected() => CanEditLayout && _currentPlan is not null;
private void NotifyCommands() private void NotifyCommands()
{ {
@@ -264,7 +286,7 @@ public partial class SeatCellViewModel : ObservableObject
public int Column { get; } public int Column { get; }
public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}"; public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}";
public ObservableCollection<StudentSeatOption> Options { get; } public ObservableCollection<StudentSeatOption> Options { get; }
public bool CanEdit { get; } [ObservableProperty] private bool _canEdit;
public bool IsOccupied => SelectedOption.StudentId.HasValue; public bool IsOccupied => SelectedOption.StudentId.HasValue;
public string StudentName => IsOccupied ? SelectedOption.DisplayName : "Freier Platz"; public string StudentName => IsOccupied ? SelectedOption.DisplayName : "Freier Platz";
@@ -276,7 +298,7 @@ public partial class SeatCellViewModel : ObservableObject
Options = options; Options = options;
_selectedOption = selectedOption; _selectedOption = selectedOption;
_onChanged = onChanged; _onChanged = onChanged;
CanEdit = canEdit; _canEdit = canEdit;
} }
partial void OnSelectedOptionChanged(StudentSeatOption value) partial void OnSelectedOptionChanged(StudentSeatOption value)
@@ -363,6 +385,11 @@ public partial class SeatAssessmentViewModel : ObservableObject
AttendanceChoices.Add(new("◇", "Schulveranstaltung", "Strg+7", AttendanceStatus.OtherSchoolEvent, SetAttendance)); AttendanceChoices.Add(new("◇", "Schulveranstaltung", "Strg+7", AttendanceStatus.OtherSchoolEvent, SetAttendance));
AttendanceChoices.Add(new("✕", "Geschwänzt", "Strg+9", AttendanceStatus.Truant, SetAttendance)); AttendanceChoices.Add(new("✕", "Geschwänzt", "Strg+9", AttendanceStatus.Truant, SetAttendance));
AttendanceChoices.Add(new("!", "Unentschuldigt", "Strg+0", AttendanceStatus.Unexcused, SetAttendance)); AttendanceChoices.Add(new("!", "Unentschuldigt", "Strg+0", AttendanceStatus.Unexcused, SetAttendance));
AttendanceChoices.Add(new("V", "Verspätet", "", AttendanceStatus.Late, SetAttendance));
AttendanceChoices.Add(new("V!", "Erheblich verspätet", "", AttendanceStatus.SignificantlyLate, SetAttendance));
AttendanceChoices.Add(new("A", "Im Unterricht abgängig", "", AttendanceStatus.LeftDuringClass, SetAttendance));
AttendanceChoices.Add(new("L", "Lerninsel", "", AttendanceStatus.LearningIsland, SetAttendance));
AttendanceChoices.Add(new("S", "Suspendiert", "", AttendanceStatus.Suspended, SetAttendance));
AttendanceChoices.Add(new("·", "Nicht kontrolliert", "Strg+X", null, SetAttendance)); AttendanceChoices.Add(new("·", "Nicht kontrolliert", "Strg+X", null, SetAttendance));
} }
@@ -579,10 +606,12 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
[ObservableProperty] private string _room = ""; [ObservableProperty] private string _room = "";
[ObservableProperty] private decimal _rows = 4; [ObservableProperty] private decimal _rows = 4;
[ObservableProperty] private decimal _columns = 4; [ObservableProperty] private decimal _columns = 4;
[ObservableProperty] private bool _isBoardAtBottom;
[ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _layoutError = ""; [ObservableProperty] private string _layoutError = "";
public SeatingPlan? Result { get; private set; } public SeatingPlan? Result { get; private set; }
public ObservableCollection<ColumnGapEditor> ColumnGaps { get; } = [];
public string DialogTitle => _editingPlan is null ? "Neuen Sitzplan anlegen" : "Sitzplan bearbeiten"; public string DialogTitle => _editingPlan is null ? "Neuen Sitzplan anlegen" : "Sitzplan bearbeiten";
public string SaveButtonText => _editingPlan is null ? "Anlegen" : "Speichern"; public string SaveButtonText => _editingPlan is null ? "Anlegen" : "Speichern";
@@ -591,11 +620,26 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
_plans = plans; _plans = plans;
_groupId = groupId; _groupId = groupId;
_editingPlan = editingPlan; _editingPlan = editingPlan;
RebuildColumnGaps(decimal.ToInt32(Columns));
if (editingPlan is null) return; if (editingPlan is null) return;
Name = editingPlan.Name ?? ""; Name = editingPlan.Name ?? "";
Room = editingPlan.Room ?? ""; Room = editingPlan.Room ?? "";
Rows = editingPlan.Rows; Rows = editingPlan.Rows;
Columns = editingPlan.Columns; Columns = editingPlan.Columns;
IsBoardAtBottom = editingPlan.IsBoardAtBottom;
var savedGapWidths = editingPlan.ColumnGapWidths ?? [];
for (var i = 0; i < ColumnGaps.Count && i < savedGapWidths.Count; i++)
ColumnGaps[i].Width = (decimal)savedGapWidths[i];
}
partial void OnColumnsChanged(decimal value) => RebuildColumnGaps(decimal.ToInt32(value));
private void RebuildColumnGaps(int columns)
{
var previous = ColumnGaps.ToDictionary(g => g.AfterColumn, g => g.Width);
ColumnGaps.Clear();
for (var afterColumn = 1; afterColumn < columns; afterColumn++)
ColumnGaps.Add(new ColumnGapEditor(afterColumn, previous.GetValueOrDefault(afterColumn)));
} }
[RelayCommand] [RelayCommand]
@@ -621,6 +665,8 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
plan.Room = Room?.Trim() ?? ""; plan.Room = Room?.Trim() ?? "";
plan.Rows = decimal.ToInt32(Rows); plan.Rows = decimal.ToInt32(Rows);
plan.Columns = decimal.ToInt32(Columns); plan.Columns = decimal.ToInt32(Columns);
plan.IsBoardAtBottom = IsBoardAtBottom;
plan.ColumnGapWidths = ColumnGaps.Select(g => decimal.ToDouble(g.Width)).ToList();
try try
{ {
_plans.Save(plan); _plans.Save(plan);
@@ -632,3 +678,10 @@ public partial class SeatingPlanDialogViewModel : ObservableObject
} }
} }
} }
public partial class ColumnGapEditor(int afterColumn, decimal width) : ObservableObject
{
public int AfterColumn { get; } = afterColumn;
public string Label => $"Nach Platz {AfterColumn}";
[ObservableProperty] private decimal _width = width;
}
@@ -3,13 +3,13 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.AddSessionDialog" x:Class="LehrerApp.Desktop.Views.Groups.AddSessionDialog"
x:DataType="vm:AddSessionDialogViewModel" x:DataType="vm:AddSessionDialogViewModel"
Title="Bewertungszeitpunkt anlegen" Title="{Binding DialogTitle}"
Width="380" SizeToContent="Height" Width="380" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner"> CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14"> <StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Neuer Bewertungszeitpunkt" Classes="dialogtitle"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
@@ -20,13 +20,14 @@
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Kommentar (optional)" FontSize="12" Opacity="0.7"/> <TextBlock Text="Kommentar (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Comment}" PlaceholderText="z. B. Stunde 12 Säure-Base-Reaktion"/> <TextBox Text="{Binding Comment}" PlaceholderText="z. B. Stunde 12 Säure-Base-Reaktion"
x:Name="CommentBox"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0"> <Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/> <Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Anlegen" HorizontalAlignment="Stretch" Click="OnSave"/> <Button Grid.Column="2" Content="{Binding SaveButtonText}" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid> </Grid>
</Grid> </Grid>
</Window> </Window>
@@ -11,8 +11,17 @@ public partial class AddSessionDialog : Window
protected override void OnOpened(EventArgs e) protected override void OnOpened(EventArgs e)
{ {
base.OnOpened(e); base.OnOpened(e);
if (DataContext is AddSessionDialogViewModel { IsEditing: true })
{
var commentBox = this.FindControl<TextBox>("CommentBox");
commentBox?.Focus();
commentBox?.SelectAll();
}
else
{
this.FindControl<TextBox>("DateBox")?.Focus(); this.FindControl<TextBox>("DateBox")?.Focus();
} }
}
private void OnSave(object? s, RoutedEventArgs e) private void OnSave(object? s, RoutedEventArgs e)
{ {
@@ -28,7 +28,7 @@
<Grid Grid.Row="2" RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto" Margin="0,12,0,0"> <Grid Grid.Row="2" RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="3"> <StackPanel Grid.Row="0" Grid.Column="0" Spacing="3">
<TextBlock FontSize="10" Opacity="0.55" TextWrapping="Wrap" <TextBlock FontSize="10" Opacity="0.55" TextWrapping="Wrap"
Text="Anwesenheit: 1 anwesend · 2 Entschuldigung offen · 5 krank/entschuldigt · 7 andere Schulveranstaltung · 9 geschwänzt · 0 unentschuldigt · X nicht kontrolliert"/> Text="Anwesenheit: 1 anwesend · 2 Entschuldigung offen · 5 krank/entschuldigt · 7 andere Schulveranstaltung · 9 geschwänzt · 0 unentschuldigt · X nicht kontrolliert · seltene Status per Klick im Menü"/>
<TextBlock FontSize="10" Opacity="0.55" TextWrapping="Wrap" <TextBlock FontSize="10" Opacity="0.55" TextWrapping="Wrap"
Text="Hausaufgaben mit Alt/⌥: 1 gemacht · 3 teilweise/Rest offen · 4 Rest nachgereicht · 5 Rest nicht nachgereicht · 7 nicht gemacht/offen · 8 vollständig nachgereicht · 0 endgültig nicht nachgereicht · X keine aufgegeben"/> Text="Hausaufgaben mit Alt/⌥: 1 gemacht · 3 teilweise/Rest offen · 4 Rest nachgereicht · 5 Rest nicht nachgereicht · 7 nicht gemacht/offen · 8 vollständig nachgereicht · 0 endgültig nicht nachgereicht · X keine aufgegeben"/>
<TextBlock FontSize="10" Opacity="0.55" <TextBlock FontSize="10" Opacity="0.55"
@@ -67,6 +67,13 @@ public partial class AttendanceHomeworkQuickInputDialog : Window
AddAttendanceItem(flyout, row, "[7] ◇ Andere Schulveranstaltung", AttendanceStatus.OtherSchoolEvent); AddAttendanceItem(flyout, row, "[7] ◇ Andere Schulveranstaltung", AttendanceStatus.OtherSchoolEvent);
AddAttendanceItem(flyout, row, "[9] ✕ Geschwänzt", AttendanceStatus.Truant); AddAttendanceItem(flyout, row, "[9] ✕ Geschwänzt", AttendanceStatus.Truant);
AddAttendanceItem(flyout, row, "[0] ! Unentschuldigt", AttendanceStatus.Unexcused); AddAttendanceItem(flyout, row, "[0] ! Unentschuldigt", AttendanceStatus.Unexcused);
flyout.Items.Add(new Separator());
AddAttendanceItem(flyout, row, "V Verspätet", AttendanceStatus.Late);
AddAttendanceItem(flyout, row, "V! Erheblich verspätet", AttendanceStatus.SignificantlyLate);
AddAttendanceItem(flyout, row, "A Während des Unterrichts abgängig", AttendanceStatus.LeftDuringClass);
AddAttendanceItem(flyout, row, "L Lerninsel", AttendanceStatus.LearningIsland);
AddAttendanceItem(flyout, row, "S Suspendiert", AttendanceStatus.Suspended);
flyout.Items.Add(new Separator());
AddAttendanceItem(flyout, row, "[X] · Nicht kontrolliert", null); AddAttendanceItem(flyout, row, "[X] · Nicht kontrolliert", null);
button.Flyout = flyout; button.Flyout = flyout;
@@ -53,13 +53,16 @@
Text="{Binding SelectedSessionDisplay}" Text="{Binding SelectedSessionDisplay}"
FontSize="13" FontWeight="SemiBold" FontSize="13" FontWeight="SemiBold"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
IsVisible="{Binding HasCompetencyCatalog}"> <Button Content="✎ Sitzung bearbeiten" Command="{Binding EditSessionCommand}"
IsVisible="{Binding !IsReadOnly}" FontSize="11" Padding="8,3"/>
<ToggleButton Content="◇ Kompetenzen" <ToggleButton Content="◇ Kompetenzen"
IsChecked="{Binding CompetencyTagsVisible}" IsChecked="{Binding CompetencyTagsVisible}"
IsVisible="{Binding HasCompetencyCatalog}"
FontSize="11" Padding="8,3"/> FontSize="11" Padding="8,3"/>
<ToggleButton Content="◈ Schüler-Bewertungen" <ToggleButton Content="◈ Schüler-Bewertungen"
IsChecked="{Binding StudentCompetencyRatingsVisible}" IsChecked="{Binding StudentCompetencyRatingsVisible}"
IsVisible="{Binding HasCompetencyCatalog}"
FontSize="11" Padding="8,3"/> FontSize="11" Padding="8,3"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -23,6 +23,7 @@ public partial class ParticipationTabView : UserControl
{ {
_vm = vm; _vm = vm;
vm.OnAddSession = ShowAddSessionDialog; vm.OnAddSession = ShowAddSessionDialog;
vm.OnEditSession = ShowEditSessionDialog;
vm.OnQuickInput = ShowQuickInputDialog; vm.OnQuickInput = ShowQuickInputDialog;
vm.OnStatusQuickInput = ShowStatusQuickInputDialog; vm.OnStatusQuickInput = ShowStatusQuickInputDialog;
vm.OnComputeGrade = ShowComputeGradeDialog; vm.OnComputeGrade = ShowComputeGradeDialog;
@@ -283,6 +284,17 @@ public partial class ParticipationTabView : UserControl
return ok ? vm.Result : null; return ok ? vm.Result : null;
} }
private async Task<LehrerApp.Core.Models.ParticipationSession?> ShowEditSessionDialog(
LehrerApp.Core.Models.ParticipationSession session)
{
var vm = new AddSessionDialogViewModel(session);
var dialog = new AddSessionDialog { DataContext = vm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var ok = await dialog.ShowDialog<bool>(owner);
return ok ? vm.Result : null;
}
private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm) private async Task ShowQuickInputDialog(ParticipationTabViewModel tabVm)
{ {
if (tabVm.StudentRows.Count == 0) return; if (tabVm.StudentRows.Count == 0) return;
@@ -3,9 +3,10 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanDialog" x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanDialog"
x:DataType="vm:SeatingPlanDialogViewModel" x:DataType="vm:SeatingPlanDialogViewModel"
Title="{Binding DialogTitle}" Width="480" Height="470" Title="{Binding DialogTitle}" Width="620" Height="680" MinHeight="560"
CanResize="False" WindowStartupLocation="CenterOwner"> CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer>
<StackPanel Spacing="16"> <StackPanel Spacing="16">
<StackPanel Spacing="3"> <StackPanel Spacing="3">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
@@ -40,12 +41,39 @@
<TextBlock Text="{Binding LayoutError}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding LayoutError}" Foreground="Red" FontSize="11"
IsVisible="{Binding LayoutError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding LayoutError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="4">
<TextBlock Text="Tafelposition" FontSize="12" Opacity="0.7"/>
<CheckBox Content="Tafel unterhalb der Sitzplätze anzeigen"
IsChecked="{Binding IsBoardAtBottom}"/>
</StackPanel>
<StackPanel Spacing="7">
<TextBlock Text="Abstände zwischen den Tischen" FontSize="12" Opacity="0.7"/>
<TextBlock Text="Breite 0 bedeutet: kein zusätzlicher Abstand. So lassen sich z. B. Tischgruppen und Mittelgänge abbilden."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding ColumnGaps}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ColumnGapEditor">
<StackPanel Width="125" Margin="0,0,10,8" Spacing="3">
<TextBlock Text="{Binding Label}" FontSize="11" Opacity="0.65"/>
<NumericUpDown Value="{Binding Width}" Minimum="0" Maximum="300"
Increment="10" FormatString="0 px"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}" <Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
CornerRadius="6" Padding="12"> CornerRadius="6" Padding="12">
<TextBlock Text="Beim Verkleinern des Rasters entfallen Zuordnungen außerhalb der neuen Größe." <TextBlock Text="Beim Verkleinern des Rasters entfallen Zuordnungen außerhalb der neuen Größe."
FontSize="12" Opacity="0.7" TextWrapping="Wrap"/> FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
</Border> </Border>
</StackPanel> </StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0"> <Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/> <Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
@@ -0,0 +1,77 @@
using Avalonia;
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
/// <summary>
/// Ordnet Sitzplätze in einem Raster an und fügt zwischen ausgewählten Spalten zusätzliche
/// transparente Breite ein. Anders als ein UniformGrid kann der Abstand je Spaltengrenze variieren.
/// </summary>
public sealed class SeatingPlanPanel : Panel
{
public static readonly StyledProperty<int> ColumnsProperty =
AvaloniaProperty.Register<SeatingPlanPanel, int>(nameof(Columns), 1);
public static readonly StyledProperty<IReadOnlyList<double>> ColumnGapWidthsProperty =
AvaloniaProperty.Register<SeatingPlanPanel, IReadOnlyList<double>>(
nameof(ColumnGapWidths), Array.Empty<double>());
public int Columns
{
get => GetValue(ColumnsProperty);
set => SetValue(ColumnsProperty, value);
}
public IReadOnlyList<double> ColumnGapWidths
{
get => GetValue(ColumnGapWidthsProperty);
set => SetValue(ColumnGapWidthsProperty, value);
}
static SeatingPlanPanel() =>
AffectsMeasure<SeatingPlanPanel>(ColumnsProperty, ColumnGapWidthsProperty);
protected override Size MeasureOverride(Size availableSize)
{
foreach (var child in Children)
child.Measure(Size.Infinity);
if (Children.Count == 0) return default;
var columns = Math.Max(1, Columns);
var rows = (int)Math.Ceiling((double)Children.Count / columns);
var cellWidth = Children.Max(child => child.DesiredSize.Width);
var cellHeight = Children.Max(child => child.DesiredSize.Height);
return new Size(cellWidth * columns + GapWidth(columns), cellHeight * rows);
}
protected override Size ArrangeOverride(Size finalSize)
{
if (Children.Count == 0) return finalSize;
var columns = Math.Max(1, Columns);
var cellWidth = Children.Max(child => child.DesiredSize.Width);
var cellHeight = Children.Max(child => child.DesiredSize.Height);
for (var index = 0; index < Children.Count; index++)
{
var column = index % columns;
var row = index / columns;
var child = Children[index];
var x = column * cellWidth + GapWidthBefore(column);
var childX = x + Math.Max(0, (cellWidth - child.DesiredSize.Width) / 2);
var childY = row * cellHeight + Math.Max(0, (cellHeight - child.DesiredSize.Height) / 2);
child.Arrange(new Rect(childX, childY, child.DesiredSize.Width, child.DesiredSize.Height));
}
return finalSize;
}
private double GapWidth(int columns) =>
Enumerable.Range(0, Math.Max(0, columns - 1)).Sum(GapAt);
private double GapWidthBefore(int column) =>
Enumerable.Range(0, Math.Max(0, column)).Sum(GapAt);
private double GapAt(int index) => index < ColumnGapWidths.Count
? Math.Clamp(ColumnGapWidths[index], 0, 300)
: 0;
}
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl 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:groups="clr-namespace:LehrerApp.Desktop.Views.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanTabView" x:Class="LehrerApp.Desktop.Views.Groups.SeatingPlanTabView"
x:DataType="vm:SeatingPlanTabViewModel"> x:DataType="vm:SeatingPlanTabViewModel">
<UserControl.Styles> <UserControl.Styles>
@@ -37,7 +38,7 @@
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
<StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,0"> <StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,0" IsVisible="{Binding IsEditMode}">
<Button Content=" Sitzplan" Command="{Binding AddPlanCommand}" HorizontalAlignment="Stretch"/> <Button Content=" Sitzplan" Command="{Binding AddPlanCommand}" HorizontalAlignment="Stretch"/>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding HasSelectedPlan}"> <Grid ColumnDefinitions="*,8,*" IsVisible="{Binding HasSelectedPlan}">
<Button Grid.Column="0" Content="Bearbeiten" Command="{Binding EditPlanCommand}" <Button Grid.Column="0" Content="Bearbeiten" Command="{Binding EditPlanCommand}"
@@ -60,30 +61,41 @@
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<Grid RowDefinitions="Auto,*" IsVisible="{Binding HasSelectedPlan}" Margin="24"> <Grid RowDefinitions="Auto,*" ColumnDefinitions="*,Auto" IsVisible="{Binding HasSelectedPlan}" Margin="24">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,18"> <Grid Grid.Row="0" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
<StackPanel Spacing="3"> <StackPanel Spacing="3">
<TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/> <TextBlock Text="{Binding PlanTitle}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/> <TextBlock Text="{Binding PlanSubtitle}" Opacity="0.65"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Bottom"> <StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
IsVisible="{Binding IsEditable}" HorizontalAlignment="Right"/>
<TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/> <TextBlock Text="{Binding AssignmentSummary}" HorizontalAlignment="Right" FontSize="12" Opacity="0.6"/>
<TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right" <TextBlock Text="Ziehen: Platz ändern · Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5"/> FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode}"/>
<TextBlock Text="Klicken: bewerten" HorizontalAlignment="Right"
FontSize="11" Opacity="0.5" IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" <ScrollViewer x:Name="RoomScrollViewer" Grid.Row="1" Grid.Column="0"
VerticalScrollBarVisibility="Auto"> Margin="0,0,16,0"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnRoomDragOver"
DragDrop.DragLeave="OnRoomDragLeave">
<StackPanel Spacing="14" HorizontalAlignment="Center"> <StackPanel Spacing="14" HorizontalAlignment="Center">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}" <Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
CornerRadius="6" Padding="36,8" HorizontalAlignment="Center"> CornerRadius="6" Padding="36,8" HorizontalAlignment="Center"
IsVisible="{Binding IsBoardAtTop}">
<TextBlock Text="Tafel / Vorderseite" FontWeight="SemiBold" Opacity="0.75"/> <TextBlock Text="Tafel / Vorderseite" FontWeight="SemiBold" Opacity="0.75"/>
</Border> </Border>
<ItemsControl ItemsSource="{Binding Seats}" HorizontalAlignment="Center"> <ItemsControl ItemsSource="{Binding Seats}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<UniformGrid Columns="{Binding PlanColumns}"/> <groups:SeatingPlanPanel Columns="{Binding PlanColumns}"
ColumnGapWidths="{Binding ColumnGapWidths}"/>
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
@@ -104,7 +116,7 @@
<StackPanel Spacing="5"> <StackPanel Spacing="5">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding PositionLabel}" FontSize="10" Opacity="0.5"/> <TextBlock Text="{Binding PositionLabel}" FontSize="10" Opacity="0.5"/>
<TextBlock Grid.Column="1" Text="⠿" Opacity="0.45" IsVisible="{Binding IsOccupied}"/> <TextBlock Grid.Column="1" Text="⠿" Opacity="0.45" IsVisible="{Binding CanEdit}"/>
</Grid> </Grid>
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13" <TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
@@ -113,38 +125,52 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
CornerRadius="6" Padding="36,8" HorizontalAlignment="Center"
IsVisible="{Binding IsBoardAtBottom}">
<TextBlock Text="Tafel / Vorderseite" FontWeight="SemiBold" Opacity="0.75"/>
</Border>
</StackPanel>
</ScrollViewer>
<Border DragDrop.AllowDrop="True" DragDrop.DragOver="OnUnassignedDragOver" <!-- Die Schülerliste hat einen eigenen Scrollbereich und bleibt dadurch auch bei
großen Räumen neben den aktuell sichtbaren Sitzplätzen erreichbar. -->
<Border Grid.Row="1" Grid.Column="1"
Width="220" IsVisible="{Binding IsEditMode}"
DragDrop.AllowDrop="True" DragDrop.DragOver="OnUnassignedDragOver"
DragDrop.Drop="OnUnassignedDrop" DragDrop.Drop="OnUnassignedDrop"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7" Padding="12" Margin="5,8"> BorderThickness="1" CornerRadius="7" Padding="12">
<StackPanel Spacing="8"> <Grid RowDefinitions="Auto,Auto,*">
<TextBlock Text="NICHT ZUGEORDNET" FontSize="10" FontWeight="Bold" Opacity="0.5"/> <TextBlock Grid.Row="0" Text="NICHT ZUGEORDNET" FontSize="10"
<TextBlock Text="Schüler hierher ziehen, um einen Platz zu leeren." FontWeight="Bold" Opacity="0.5"/>
FontSize="11" Opacity="0.5"/> <TextBlock Grid.Row="1" Margin="0,5,0,10"
Text="Schüler auf einen Platz ziehen. Belegte Plätze hierher ziehen, um sie zu leeren."
TextWrapping="Wrap" FontSize="11" Opacity="0.5"/>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="6">
<ItemsControl ItemsSource="{Binding UnassignedStudents}"> <ItemsControl ItemsSource="{Binding UnassignedStudents}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentSeatOption"> <DataTemplate x:DataType="vm:StudentSeatOption">
<Border Background="{DynamicResource SystemAccentColorLight2}" CornerRadius="5" <Border Background="{DynamicResource SystemAccentColorLight2}" CornerRadius="5"
Padding="9,5" Margin="0,0,6,6" Padding="9,7"
PointerPressed="OnDragSourcePressed" PointerPressed="OnDragSourcePressed"
PointerMoved="OnDragSourceMoved" PointerMoved="OnDragSourceMoved"
PointerReleased="OnDragSourceReleased"> PointerReleased="OnDragSourceReleased">
<TextBlock Text="{Binding DisplayName}" FontSize="12"/> <TextBlock Text="{Binding DisplayName}" FontSize="12" TextTrimming="CharacterEllipsis"/>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Alle Schüler sind zugeordnet." Classes="emptyhint" <TextBlock Text="Alle Schüler sind zugeordnet." Classes="emptyhint"
TextWrapping="Wrap"
IsVisible="{Binding !UnassignedStudents.Count}"/> IsVisible="{Binding !UnassignedStudents.Count}"/>
</StackPanel> </StackPanel>
</Border>
</StackPanel>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
</Border>
</Grid>
</Grid> </Grid>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -10,6 +10,9 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class SeatingPlanTabView : UserControl public partial class SeatingPlanTabView : UserControl
{ {
private const double AutoScrollEdgeSize = 72;
private const double AutoScrollStep = 14;
private object? _dragCandidate; private object? _dragCandidate;
private PointerPressedEventArgs? _dragTrigger; private PointerPressedEventArgs? _dragTrigger;
private Avalonia.Point _pressPosition; private Avalonia.Point _pressPosition;
@@ -18,8 +21,15 @@ public partial class SeatingPlanTabView : UserControl
private SeatCellViewModel? _pendingDropTarget; private SeatCellViewModel? _pendingDropTarget;
private bool _pendingClearSeat; private bool _pendingClearSeat;
private DateTime _ignoreTapUntil; private DateTime _ignoreTapUntil;
private readonly DispatcherTimer _autoScrollTimer;
private Avalonia.Vector _autoScrollDirection;
public SeatingPlanTabView() => InitializeComponent(); public SeatingPlanTabView()
{
InitializeComponent();
_autoScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
_autoScrollTimer.Tick += OnAutoScrollTick;
}
protected override void OnDataContextChanged(EventArgs e) protected override void OnDataContextChanged(EventArgs e)
{ {
@@ -38,7 +48,7 @@ public partial class SeatingPlanTabView : UserControl
!e.GetCurrentPoint(control).Properties.IsLeftButtonPressed) return; !e.GetCurrentPoint(control).Properties.IsLeftButtonPressed) return;
var candidate = control.DataContext; var candidate = control.DataContext;
if (candidate is SeatCellViewModel { IsOccupied: false } or null) return; if (candidate is SeatCellViewModel { IsOccupied: false } or null) return;
if (DataContext is not SeatingPlanTabViewModel { IsEditable: true }) return; if (DataContext is not SeatingPlanTabViewModel { CanEditLayout: true }) return;
_dragCandidate = candidate; _dragCandidate = candidate;
_dragTrigger = e; _dragTrigger = e;
_pressPosition = e.GetPosition(this); _pressPosition = e.GetPosition(this);
@@ -69,6 +79,7 @@ public partial class SeatingPlanTabView : UserControl
_draggedStudent = null; _draggedStudent = null;
_pendingDropTarget = null; _pendingDropTarget = null;
_pendingClearSeat = false; _pendingClearSeat = false;
StopAutoScroll();
_ignoreTapUntil = DateTime.UtcNow.AddMilliseconds(250); _ignoreTapUntil = DateTime.UtcNow.AddMilliseconds(250);
// Never mutate an ItemsControl while Avalonia is still processing its native // Never mutate an ItemsControl while Avalonia is still processing its native
@@ -107,6 +118,7 @@ public partial class SeatingPlanTabView : UserControl
private void OnSeatDrop(object? sender, DragEventArgs e) private void OnSeatDrop(object? sender, DragEventArgs e)
{ {
StopAutoScroll();
if (sender is not Border { DataContext: SeatCellViewModel target }) return; if (sender is not Border { DataContext: SeatCellViewModel target }) return;
target.IsDropTarget = false; target.IsDropTarget = false;
if (!CanDropOn(target)) if (!CanDropOn(target))
@@ -120,7 +132,7 @@ public partial class SeatingPlanTabView : UserControl
} }
private bool CanDropOn(SeatCellViewModel target) => private bool CanDropOn(SeatCellViewModel target) =>
DataContext is SeatingPlanTabViewModel { IsEditable: true } DataContext is SeatingPlanTabViewModel { CanEditLayout: true }
&& ((_draggedSeat is not null && _draggedSeat != target) || _draggedStudent?.StudentId is not null); && ((_draggedSeat is not null && _draggedSeat != target) || _draggedStudent?.StudentId is not null);
private void OnUnassignedDragOver(object? sender, DragEventArgs e) => private void OnUnassignedDragOver(object? sender, DragEventArgs e) =>
@@ -128,6 +140,7 @@ public partial class SeatingPlanTabView : UserControl
private void OnUnassignedDrop(object? sender, DragEventArgs e) private void OnUnassignedDrop(object? sender, DragEventArgs e)
{ {
StopAutoScroll();
if (_draggedSeat is not null) if (_draggedSeat is not null)
{ {
_pendingDropTarget = null; _pendingDropTarget = null;
@@ -136,6 +149,50 @@ public partial class SeatingPlanTabView : UserControl
} }
} }
private void OnRoomDragOver(object? sender, DragEventArgs e)
{
if (_draggedSeat is null && _draggedStudent is null)
{
StopAutoScroll();
return;
}
var position = e.GetPosition(RoomScrollViewer);
_autoScrollDirection = new Avalonia.Vector(
GetAutoScrollDirection(position.X, RoomScrollViewer.Bounds.Width),
GetAutoScrollDirection(position.Y, RoomScrollViewer.Bounds.Height));
if (_autoScrollDirection == default)
StopAutoScroll();
else if (!_autoScrollTimer.IsEnabled)
_autoScrollTimer.Start();
}
private void OnRoomDragLeave(object? sender, DragEventArgs e) => StopAutoScroll();
private void OnAutoScrollTick(object? sender, EventArgs e)
{
var maxX = Math.Max(0, RoomScrollViewer.Extent.Width - RoomScrollViewer.Viewport.Width);
var maxY = Math.Max(0, RoomScrollViewer.Extent.Height - RoomScrollViewer.Viewport.Height);
var offset = RoomScrollViewer.Offset;
RoomScrollViewer.Offset = new Avalonia.Vector(
Math.Clamp(offset.X + _autoScrollDirection.X * AutoScrollStep, 0, maxX),
Math.Clamp(offset.Y + _autoScrollDirection.Y * AutoScrollStep, 0, maxY));
}
private static double GetAutoScrollDirection(double position, double viewportSize)
{
if (position < AutoScrollEdgeSize) return -1;
if (position > viewportSize - AutoScrollEdgeSize) return 1;
return 0;
}
private void StopAutoScroll()
{
_autoScrollDirection = default;
_autoScrollTimer.Stop();
}
private async void OnSeatTapped(object? sender, TappedEventArgs e) private async void OnSeatTapped(object? sender, TappedEventArgs e)
{ {
if (DateTime.UtcNow < _ignoreTapUntil || sender is not Border { DataContext: SeatCellViewModel seat } if (DateTime.UtcNow < _ignoreTapUntil || sender is not Border { DataContext: SeatCellViewModel seat }
@@ -22,16 +22,22 @@ public sealed class AttendanceBalanceServiceTests
(new DateOnly(2025, 9, 5), AttendanceStatus.Truant), (new DateOnly(2025, 9, 5), AttendanceStatus.Truant),
(new DateOnly(2025, 9, 6), AttendanceStatus.OtherSchoolEvent), (new DateOnly(2025, 9, 6), AttendanceStatus.OtherSchoolEvent),
(new DateOnly(2025, 9, 7), AttendanceStatus.ExcusePending), (new DateOnly(2025, 9, 7), AttendanceStatus.ExcusePending),
(new DateOnly(2025, 9, 8), AttendanceStatus.Late),
(new DateOnly(2025, 9, 9), AttendanceStatus.SignificantlyLate),
(new DateOnly(2025, 9, 10), AttendanceStatus.LeftDuringClass),
(new DateOnly(2025, 9, 11), AttendanceStatus.LearningIsland),
(new DateOnly(2025, 9, 12), AttendanceStatus.Suspended),
}; };
var balance = _service.Calculate(entries, From, To); var balance = _service.Calculate(entries, From, To);
Assert.Equal(7, balance.TotalChecked); Assert.Equal(12, balance.TotalChecked);
Assert.Equal(2, balance.Present); Assert.Equal(4, balance.Present);
Assert.Equal(1, balance.Excused); Assert.Equal(1, balance.Excused);
Assert.Equal(2, balance.Unexcused); // Unexcused + Truant Assert.Equal(3, balance.Unexcused); // Unexcused + Truant + im Unterricht abgängig
Assert.Equal(1, balance.SchoolEvent); Assert.Equal(3, balance.SchoolEvent); // Schulveranstaltung + Lerninsel + Suspendierung
Assert.Equal(1, balance.ExcusePending); Assert.Equal(1, balance.ExcusePending);
Assert.Equal(41.7, balance.AbsenceRatePercent);
} }
[Fact] [Fact]