Upgrade Sitzplan - Jetzt mit Bewertungsfeature
This commit is contained in:
@@ -38,7 +38,8 @@ public sealed class AttachmentStoreTests
|
|||||||
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
|
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
|
||||||
|
|
||||||
Assert.Null(store.OpenRead("user-2", "shared-id"));
|
Assert.Null(store.OpenRead("user-2", "shared-id"));
|
||||||
Assert.NotNull(store.OpenRead("user-1", "shared-id"));
|
using var user1Attachment = store.OpenRead("user-1", "shared-id");
|
||||||
|
Assert.NotNull(user1Attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ public class Documentation
|
|||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid StudentId { get; set; }
|
public Guid StudentId { get; set; }
|
||||||
public Guid? GroupId { get; set; }
|
public Guid? GroupId { get; set; }
|
||||||
|
/// <summary>Unterrichtssitzung, in der die Beobachtung entstanden ist.</summary>
|
||||||
|
public Guid? ParticipationSessionId { get; set; }
|
||||||
|
/// <summary>Optionaler Bezug auf die konkrete geplante Stunde.</summary>
|
||||||
|
public Guid? LessonId { get; set; }
|
||||||
public DocumentationType Type { get; set; }
|
public DocumentationType Type { get; set; }
|
||||||
public DateOnly Date { get; set; }
|
public DateOnly Date { get; set; }
|
||||||
public string Title { get; set; } = "";
|
public string Title { get; set; } = "";
|
||||||
@@ -17,6 +21,8 @@ public class Documentation
|
|||||||
public List<DocumentAttachment> Attachments { get; set; } = [];
|
public List<DocumentAttachment> Attachments { get; set; } = [];
|
||||||
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
|
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
|
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
|
||||||
|
public bool IsDraft { get; set; }
|
||||||
public bool IsConfidential { get; set; }
|
public bool IsConfidential { 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;
|
||||||
|
|||||||
@@ -137,6 +137,24 @@ public sealed class DocumentationDialogViewModelTests
|
|||||||
Assert.Equal("Gut verlaufen", vm.Result.ParentCallData.Impressions);
|
Assert.Equal("Gut verlaufen", vm.Result.ParentCallData.Impressions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_SitzplanEntwurf_WirdAbgeschlossenUndBehaeltStundenbezug()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var existing = new Documentation
|
||||||
|
{
|
||||||
|
StudentId = Guid.NewGuid(), Type = DocumentationType.Incident,
|
||||||
|
Date = DateOnly.FromDateTime(DateTime.Today), Title = "Streit/Konflikt",
|
||||||
|
IsDraft = true, ParticipationSessionId = sessionId, Tags = ["Streit/Konflikt"],
|
||||||
|
};
|
||||||
|
var vm = new DocumentationDialogViewModel(existing.StudentId, existing, new FakeAttachmentStorage());
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.False(vm.Result!.IsDraft);
|
||||||
|
Assert.Equal(sessionId, vm.Result.ParticipationSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
|
public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -146,6 +146,66 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.All(vm.Seats, seat => Assert.True(seat.CanEdit));
|
Assert.All(vm.Seats, seat => Assert.True(seat.CanEdit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ansichtsmodus_ZeigtAnwesenheitUndHausaufgabeUndBlendetAbwesendeAb()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var session = new ParticipationSession { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today) };
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
entries.Add(new ParticipationEntry
|
||||||
|
{
|
||||||
|
SessionId = session.Id, StudentId = student.Id,
|
||||||
|
Attendance = AttendanceStatus.Excused, Homework = HomeworkStatus.MissingOpen,
|
||||||
|
});
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
new FakeSessions([session]), entries, new FakeAspects());
|
||||||
|
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
|
||||||
|
var seat = Assert.Single(vm.Seats);
|
||||||
|
Assert.True(seat.HasAttendanceBadge);
|
||||||
|
Assert.True(seat.HasHomeworkBadge);
|
||||||
|
Assert.Equal(0.42, seat.LessonOpacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SituationsTags_WerdenProSchuelerUndSitzungInEinemEntwurfZusammengefuehrt()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var session = new ParticipationSession
|
||||||
|
{
|
||||||
|
GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), LessonId = Guid.NewGuid(),
|
||||||
|
};
|
||||||
|
var docs = new FakeDocumentation();
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
new FakeSessions([session]), new FakeEntries(), new FakeAspects(), docs);
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
var seat = Assert.Single(vm.Seats);
|
||||||
|
|
||||||
|
seat.SituationTags.Single(t => t.Text == "Mitarbeit verweigert").ToggleCommand.Execute(null);
|
||||||
|
seat.SituationTags.Single(t => t.Text == "Material vergessen").ToggleCommand.Execute(null);
|
||||||
|
|
||||||
|
var draft = Assert.Single(docs.GetByStudent(student.Id));
|
||||||
|
Assert.True(draft.IsDraft);
|
||||||
|
Assert.Equal(session.Id, draft.ParticipationSessionId);
|
||||||
|
Assert.Equal(session.LessonId, draft.LessonId);
|
||||||
|
Assert.Equal(["Mitarbeit verweigert", "Material vergessen"], draft.Tags);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tischabstaende_WerdenBeimBearbeitenProSpaltengrenzeGespeichert()
|
public void Tischabstaende_WerdenBeimBearbeitenProSpaltengrenzeGespeichert()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using LehrerApp.Desktop.ViewModels.Groups;
|
|||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
using LehrerApp.Desktop.Views;
|
using LehrerApp.Desktop.Views;
|
||||||
|
using LehrerApp.Sync;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop;
|
namespace LehrerApp.Desktop;
|
||||||
@@ -66,6 +67,8 @@ public class App : Application
|
|||||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
WireCallbacks(mainVm);
|
WireCallbacks(mainVm);
|
||||||
var main = new MainWindow { DataContext = mainVm };
|
var main = new MainWindow { DataContext = mainVm };
|
||||||
|
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||||
|
main.EnableFinalSync(syncEngine);
|
||||||
desktop.MainWindow = main;
|
desktop.MainWindow = main;
|
||||||
if (showImmediately) main.Show();
|
if (showImmediately) main.Show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private StudentOption _selectedStudentFilter = AllStudentsOption;
|
[ObservableProperty] private StudentOption _selectedStudentFilter = AllStudentsOption;
|
||||||
[ObservableProperty] private bool _onlyThisGroup;
|
[ObservableProperty] private bool _onlyThisGroup;
|
||||||
|
[ObservableProperty] private int _draftCount;
|
||||||
|
|
||||||
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
|
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
|
||||||
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
|
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
|
||||||
@@ -80,7 +81,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
var all = relevantStudentIds
|
var all = relevantStudentIds
|
||||||
.SelectMany(id => _docs.GetByStudent(id))
|
.SelectMany(id => _docs.GetByStudent(id))
|
||||||
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
|
||||||
.OrderByDescending(d => d.Date);
|
.OrderByDescending(d => d.IsDraft)
|
||||||
|
.ThenByDescending(d => d.Date)
|
||||||
|
.ToList();
|
||||||
|
DraftCount = all.Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
|
||||||
|
|
||||||
foreach (var d in all)
|
foreach (var d in all)
|
||||||
{
|
{
|
||||||
@@ -91,6 +95,8 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Refresh() => Load();
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddDocumentation()
|
private async Task AddDocumentation()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
ParticipationTab.LoadSessions();
|
ParticipationTab.LoadSessions();
|
||||||
ParticipationTab.RefreshCurrentGrid();
|
ParticipationTab.RefreshCurrentGrid();
|
||||||
};
|
};
|
||||||
|
SeatingPlanTab.OnDocumentationChanged = GroupDocumentationTab.Refresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadGroup(Guid id)
|
public void LoadGroup(Guid id)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
private readonly IParticipationSessionRepository _sessions;
|
private readonly IParticipationSessionRepository _sessions;
|
||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
private readonly IParticipationAspectRepository _aspects;
|
private readonly IParticipationAspectRepository _aspects;
|
||||||
|
private readonly IDocumentationRepository? _documentation;
|
||||||
private Guid _groupId;
|
private Guid _groupId;
|
||||||
private SeatingPlan? _currentPlan;
|
private SeatingPlan? _currentPlan;
|
||||||
private bool _isReadOnly;
|
private bool _isReadOnly;
|
||||||
@@ -28,11 +29,13 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isBoardAtBottom;
|
[ObservableProperty] private bool _isBoardAtBottom;
|
||||||
[ObservableProperty] private IReadOnlyList<double> _columnGapWidths = [];
|
[ObservableProperty] private IReadOnlyList<double> _columnGapWidths = [];
|
||||||
[ObservableProperty] private bool _isEditMode;
|
[ObservableProperty] private bool _isEditMode;
|
||||||
|
[ObservableProperty] private ParticipationSessionOption? _selectedSession;
|
||||||
|
|
||||||
public ObservableCollection<SeatingPlanSummary> Plans { get; } = [];
|
public ObservableCollection<SeatingPlanSummary> Plans { get; } = [];
|
||||||
public ObservableCollection<SeatCellViewModel> Seats { get; } = [];
|
public ObservableCollection<SeatCellViewModel> Seats { get; } = [];
|
||||||
public ObservableCollection<StudentSeatOption> StudentOptions { get; } = [];
|
public ObservableCollection<StudentSeatOption> StudentOptions { get; } = [];
|
||||||
public ObservableCollection<StudentSeatOption> UnassignedStudents { get; } = [];
|
public ObservableCollection<StudentSeatOption> UnassignedStudents { get; } = [];
|
||||||
|
public ObservableCollection<ParticipationSessionOption> TodaySessions { get; } = [];
|
||||||
|
|
||||||
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;
|
||||||
@@ -42,10 +45,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
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; }
|
||||||
public Action? OnAssessmentChanged { get; set; }
|
public Action? OnAssessmentChanged { get; set; }
|
||||||
|
public Action? OnDocumentationChanged { get; set; }
|
||||||
|
|
||||||
public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students,
|
public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students,
|
||||||
IGroupMembershipRepository memberships, IParticipationSessionRepository sessions,
|
IGroupMembershipRepository memberships, IParticipationSessionRepository sessions,
|
||||||
IParticipationRepository participation, IParticipationAspectRepository aspects)
|
IParticipationRepository participation, IParticipationAspectRepository aspects,
|
||||||
|
IDocumentationRepository? documentation = null)
|
||||||
{
|
{
|
||||||
_plans = plans;
|
_plans = plans;
|
||||||
_students = students;
|
_students = students;
|
||||||
@@ -53,6 +58,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
_sessions = sessions;
|
_sessions = sessions;
|
||||||
_participation = participation;
|
_participation = participation;
|
||||||
_aspects = aspects;
|
_aspects = aspects;
|
||||||
|
_documentation = documentation;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) =>
|
public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) =>
|
||||||
@@ -63,12 +69,31 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
_groupId = groupId;
|
_groupId = groupId;
|
||||||
_isReadOnly = isReadOnly;
|
_isReadOnly = isReadOnly;
|
||||||
IsEditMode = false;
|
IsEditMode = false;
|
||||||
|
LoadTodaySessions();
|
||||||
LoadStudentOptions();
|
LoadStudentOptions();
|
||||||
ReloadPlans();
|
ReloadPlans();
|
||||||
OnPropertyChanged(nameof(IsEditable));
|
OnPropertyChanged(nameof(IsEditable));
|
||||||
NotifyCommands();
|
NotifyCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LoadTodaySessions()
|
||||||
|
{
|
||||||
|
TodaySessions.Clear();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today)
|
||||||
|
.OrderBy(s => s.CreatedAt).ToList();
|
||||||
|
if (sessions.Count == 0 && !_isReadOnly)
|
||||||
|
{
|
||||||
|
var created = new ParticipationSession { GroupId = _groupId, Date = today, Comment = "Sitzplan" };
|
||||||
|
_sessions.Save(created);
|
||||||
|
sessions.Add(created);
|
||||||
|
}
|
||||||
|
foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session));
|
||||||
|
SelectedSession = TodaySessions.LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData();
|
||||||
|
|
||||||
private void LoadStudentOptions()
|
private void LoadStudentOptions()
|
||||||
{
|
{
|
||||||
StudentOptions.Clear();
|
StudentOptions.Clear();
|
||||||
@@ -133,14 +158,78 @@ 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, CanEditLayout));
|
Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged,
|
||||||
|
CanEditLayout, ToggleSituationTag, IsEditable));
|
||||||
}
|
}
|
||||||
UpdateAssignmentSummary();
|
UpdateAssignmentSummary();
|
||||||
|
RefreshSeatLessonData();
|
||||||
}
|
}
|
||||||
OnPropertyChanged(nameof(HasSelectedPlan));
|
OnPropertyChanged(nameof(HasSelectedPlan));
|
||||||
NotifyCommands();
|
NotifyCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RefreshSeatLessonData()
|
||||||
|
{
|
||||||
|
if (SelectedSession is null)
|
||||||
|
{
|
||||||
|
foreach (var seat in Seats) seat.SetLessonData(null, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var entries = _participation.GetBySession(SelectedSession.Id)
|
||||||
|
.ToDictionary(e => e.StudentId);
|
||||||
|
foreach (var seat in Seats)
|
||||||
|
{
|
||||||
|
if (seat.SelectedOption.StudentId is not Guid studentId)
|
||||||
|
{
|
||||||
|
seat.SetLessonData(null, []);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.TryGetValue(studentId, out var entry);
|
||||||
|
var tags = _documentation?.GetByStudent(studentId)
|
||||||
|
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id)?.Tags ?? [];
|
||||||
|
seat.SetLessonData(entry, tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
|
||||||
|
{
|
||||||
|
if (!IsEditable || _documentation is null || SelectedSession is null ||
|
||||||
|
seat.SelectedOption.StudentId is not Guid studentId) return;
|
||||||
|
var draft = _documentation.GetByStudent(studentId)
|
||||||
|
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id);
|
||||||
|
if (draft is null)
|
||||||
|
{
|
||||||
|
draft = new Documentation
|
||||||
|
{
|
||||||
|
StudentId = studentId, GroupId = _groupId,
|
||||||
|
ParticipationSessionId = SelectedSession.Id,
|
||||||
|
LessonId = SelectedSession.LessonId,
|
||||||
|
Type = DocumentationType.Incident,
|
||||||
|
Date = SelectedSession.Date,
|
||||||
|
Title = tag,
|
||||||
|
IsDraft = true,
|
||||||
|
Tags = [tag],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (draft.Tags.Contains(tag))
|
||||||
|
{
|
||||||
|
draft.Tags.Remove(tag);
|
||||||
|
if (draft.Tags.Count == 0)
|
||||||
|
{
|
||||||
|
_documentation.Delete(draft.Id);
|
||||||
|
RefreshSeatLessonData();
|
||||||
|
OnDocumentationChanged?.Invoke();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.Title = draft.Tags[0];
|
||||||
|
}
|
||||||
|
else draft.Tags.Add(tag);
|
||||||
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_documentation.Save(draft);
|
||||||
|
RefreshSeatLessonData();
|
||||||
|
OnDocumentationChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnSeatChanged(SeatCellViewModel changed)
|
private void OnSeatChanged(SeatCellViewModel changed)
|
||||||
{
|
{
|
||||||
if (_currentPlan is null || !CanEditLayout) return;
|
if (_currentPlan is null || !CanEditLayout) return;
|
||||||
@@ -209,8 +298,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
|
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
|
||||||
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
|
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
|
||||||
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable);
|
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
|
||||||
|
SelectedSession?.Id);
|
||||||
await OnAssessStudent(assessment);
|
await OnAssessStudent(assessment);
|
||||||
|
RefreshSeatLessonData();
|
||||||
OnAssessmentChanged?.Invoke();
|
OnAssessmentChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +332,11 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
partial void OnIsEditModeChanged(bool value)
|
partial void OnIsEditModeChanged(bool value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanEditLayout));
|
OnPropertyChanged(nameof(CanEditLayout));
|
||||||
foreach (var seat in Seats) seat.CanEdit = CanEditLayout;
|
foreach (var seat in Seats)
|
||||||
|
{
|
||||||
|
seat.CanEdit = CanEditLayout;
|
||||||
|
seat.CanRecordLesson = IsEditable && !value;
|
||||||
|
}
|
||||||
NotifyCommands();
|
NotifyCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,10 +370,21 @@ public sealed record StudentSeatOption(Guid? StudentId, string DisplayName)
|
|||||||
public static StudentSeatOption Empty { get; } = new(null, "— frei —");
|
public static StudentSeatOption Empty { get; } = new(null, "— frei —");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ParticipationSessionOption(ParticipationSession session)
|
||||||
|
{
|
||||||
|
public Guid Id => session.Id;
|
||||||
|
public Guid? LessonId => session.LessonId;
|
||||||
|
public DateOnly Date => session.Date;
|
||||||
|
public string DisplayName => string.IsNullOrWhiteSpace(session.Comment)
|
||||||
|
? $"{session.Date:dd.MM.yyyy}"
|
||||||
|
: $"{session.Date:dd.MM.yyyy} · {session.Comment}";
|
||||||
|
}
|
||||||
|
|
||||||
public partial class SeatCellViewModel : ObservableObject
|
public partial class SeatCellViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly Action<SeatCellViewModel> _onChanged;
|
private readonly Action<SeatCellViewModel> _onChanged;
|
||||||
private bool _suppressChange;
|
private bool _suppressChange;
|
||||||
|
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
|
||||||
|
|
||||||
[ObservableProperty] private StudentSeatOption _selectedOption;
|
[ObservableProperty] private StudentSeatOption _selectedOption;
|
||||||
[ObservableProperty] private bool _isDropTarget;
|
[ObservableProperty] private bool _isDropTarget;
|
||||||
@@ -287,11 +393,20 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
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; }
|
||||||
[ObservableProperty] private bool _canEdit;
|
[ObservableProperty] private bool _canEdit;
|
||||||
|
[ObservableProperty] private double _lessonOpacity = 1.0;
|
||||||
|
[ObservableProperty] private string _attendanceBadge = "";
|
||||||
|
[ObservableProperty] private string _homeworkBadge = "";
|
||||||
|
public ObservableCollection<SituationTagChoice> SituationTags { get; } = [];
|
||||||
|
public bool HasAttendanceBadge => AttendanceBadge.Length > 0;
|
||||||
|
public bool HasHomeworkBadge => HomeworkBadge.Length > 0;
|
||||||
|
public bool ShowLessonOverview => IsOccupied && !CanEdit;
|
||||||
|
[ObservableProperty] private bool _canRecordLesson;
|
||||||
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";
|
||||||
|
|
||||||
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
|
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
|
||||||
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit)
|
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
|
||||||
|
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false)
|
||||||
{
|
{
|
||||||
Row = row;
|
Row = row;
|
||||||
Column = column;
|
Column = column;
|
||||||
@@ -299,21 +414,54 @@ public partial class SeatCellViewModel : ObservableObject
|
|||||||
_selectedOption = selectedOption;
|
_selectedOption = selectedOption;
|
||||||
_onChanged = onChanged;
|
_onChanged = onChanged;
|
||||||
_canEdit = canEdit;
|
_canEdit = canEdit;
|
||||||
|
_toggleSituationTag = toggleSituationTag ?? ((_, _) => { });
|
||||||
|
_canRecordLesson = canRecordLesson;
|
||||||
|
foreach (var tag in SituationTagChoice.DefaultTags)
|
||||||
|
SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
partial void OnSelectedOptionChanged(StudentSeatOption value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsOccupied));
|
OnPropertyChanged(nameof(IsOccupied));
|
||||||
OnPropertyChanged(nameof(StudentName));
|
OnPropertyChanged(nameof(StudentName));
|
||||||
|
OnPropertyChanged(nameof(ShowLessonOverview));
|
||||||
if (!_suppressChange) _onChanged(this);
|
if (!_suppressChange) _onChanged(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnCanEditChanged(bool value) => OnPropertyChanged(nameof(ShowLessonOverview));
|
||||||
|
|
||||||
public void SetSelectionSilently(StudentSeatOption option)
|
public void SetSelectionSilently(StudentSeatOption option)
|
||||||
{
|
{
|
||||||
_suppressChange = true;
|
_suppressChange = true;
|
||||||
SelectedOption = option;
|
SelectedOption = option;
|
||||||
_suppressChange = false;
|
_suppressChange = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetLessonData(ParticipationEntry? entry, IEnumerable<string> tags)
|
||||||
|
{
|
||||||
|
var attendance = entry?.Attendance;
|
||||||
|
AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance);
|
||||||
|
HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry));
|
||||||
|
LessonOpacity = attendance is not null and not AttendanceStatus.Present
|
||||||
|
and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0;
|
||||||
|
var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text);
|
||||||
|
OnPropertyChanged(nameof(HasAttendanceBadge));
|
||||||
|
OnPropertyChanged(nameof(HasHomeworkBadge));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class SituationTagChoice(string text, bool isSelected, Action<string> toggle) : ObservableObject
|
||||||
|
{
|
||||||
|
public static readonly string[] DefaultTags =
|
||||||
|
[
|
||||||
|
"Mitarbeit verweigert", "Unterricht gestört", "Streit/Konflikt",
|
||||||
|
"Langer Toilettengang", "Material vergessen", "Handynutzung",
|
||||||
|
"Besonders hilfsbereit", "Sehr gute Mitarbeit", "Gespräch erforderlich",
|
||||||
|
];
|
||||||
|
public string Text { get; } = text;
|
||||||
|
[ObservableProperty] private bool _isSelected = isSelected;
|
||||||
|
[RelayCommand] private void Toggle() => toggle(Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class SeatAssessmentViewModel : ObservableObject
|
public partial class SeatAssessmentViewModel : ObservableObject
|
||||||
@@ -338,13 +486,14 @@ public partial class SeatAssessmentViewModel : ObservableObject
|
|||||||
|
|
||||||
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
|
public SeatAssessmentViewModel(IParticipationSessionRepository sessions,
|
||||||
IParticipationRepository entries, IParticipationAspectRepository aspects,
|
IParticipationRepository entries, IParticipationAspectRepository aspects,
|
||||||
Guid groupId, Guid studentId, string studentName, bool canEdit)
|
Guid groupId, Guid studentId, string studentName, bool canEdit, Guid? sessionId = null)
|
||||||
{
|
{
|
||||||
_entries = entries;
|
_entries = entries;
|
||||||
_canEdit = canEdit;
|
_canEdit = canEdit;
|
||||||
StudentName = studentName;
|
StudentName = studentName;
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
var session = sessions.GetByGroup(groupId).FirstOrDefault(s => s.Date == today);
|
var session = sessionId.HasValue ? sessions.GetById(sessionId.Value) : null;
|
||||||
|
session ??= sessions.GetByGroup(groupId).FirstOrDefault(s => s.Date == today);
|
||||||
if (session is null && canEdit)
|
if (session is null && canEdit)
|
||||||
{
|
{
|
||||||
session = new ParticipationSession
|
session = new ParticipationSession
|
||||||
|
|||||||
@@ -339,6 +339,9 @@ public partial class DocumentationDialogViewModel : ObservableObject
|
|||||||
Result.Date = date;
|
Result.Date = date;
|
||||||
Result.Title = Title.Trim();
|
Result.Title = Title.Trim();
|
||||||
Result.Content = (Content ?? "").Trim();
|
Result.Content = (Content ?? "").Trim();
|
||||||
|
// Das bewusste Speichern im vollständigen Dialog schließt einen im Sitzplan erzeugten
|
||||||
|
// Schnellentwurf ab. Stunden- und Lesson-Bezug bleiben am bestehenden Objekt erhalten.
|
||||||
|
Result.IsDraft = false;
|
||||||
Result.IsConfidential = IsConfidential;
|
Result.IsConfidential = IsConfidential;
|
||||||
Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
|
Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
|
||||||
Result.AbsenceData = type == DocumentationType.Absence
|
Result.AbsenceData = type == DocumentationType.Absence
|
||||||
@@ -421,6 +424,7 @@ public partial class DocumentationItem : ObservableObject
|
|||||||
public bool IsConfidential { get; }
|
public bool IsConfidential { get; }
|
||||||
public bool IsParentCall { get; }
|
public bool IsParentCall { get; }
|
||||||
public bool HasAttachments { get; }
|
public bool HasAttachments { get; }
|
||||||
|
public bool IsDraft { get; }
|
||||||
public string StatusLabel { get; }
|
public string StatusLabel { get; }
|
||||||
public List<TagChip> TagChips { get; }
|
public List<TagChip> TagChips { get; }
|
||||||
/// Nur im Gruppen-Tab (5.1, GroupDocumentationTabViewModel) gefüllt — die Schüler-Detailansicht
|
/// Nur im Gruppen-Tab (5.1, GroupDocumentationTabViewModel) gefüllt — die Schüler-Detailansicht
|
||||||
@@ -446,6 +450,7 @@ public partial class DocumentationItem : ObservableObject
|
|||||||
IsRevealed = !d.IsConfidential;
|
IsRevealed = !d.IsConfidential;
|
||||||
IsParentCall = d.Type == DocumentationType.ParentCall;
|
IsParentCall = d.Type == DocumentationType.ParentCall;
|
||||||
HasAttachments = d.Attachments.Count > 0;
|
HasAttachments = d.Attachments.Count > 0;
|
||||||
|
IsDraft = d.IsDraft;
|
||||||
StatusLabel = BuildStatusLabel(d);
|
StatusLabel = BuildStatusLabel(d);
|
||||||
TagChips = d.Tags.Select(t => new TagChip(t)).ToList();
|
TagChips = d.Tags.Select(t => new TagChip(t)).ToList();
|
||||||
StudentName = studentName;
|
StudentName = studentName;
|
||||||
@@ -455,6 +460,7 @@ public partial class DocumentationItem : ObservableObject
|
|||||||
|
|
||||||
private static string BuildStatusLabel(Documentation d) => d.Type switch
|
private static string BuildStatusLabel(Documentation d) => d.Type switch
|
||||||
{
|
{
|
||||||
|
_ when d.IsDraft => "Nacharbeiten",
|
||||||
DocumentationType.ParentCall when d.ParentCallData is { IsConducted: true } pc =>
|
DocumentationType.ParentCall when d.ParentCallData is { IsConducted: true } pc =>
|
||||||
$"Durchgeführt am {pc.ConductedDate:dd.MM.yyyy}",
|
$"Durchgeführt am {pc.ConductedDate:dd.MM.yyyy}",
|
||||||
DocumentationType.ParentCall => "Noch nicht durchgeführt",
|
DocumentationType.ParentCall => "Noch nicht durchgeführt",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<Grid RowDefinitions="Auto,*" Margin="16">
|
<Grid RowDefinitions="Auto,*" Margin="16">
|
||||||
|
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,12">
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,*,Auto" Margin="0,0,0,12">
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="0,0,12,0">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="0,0,12,0">
|
||||||
<TextBlock Text="Schüler:" VerticalAlignment="Center"/>
|
<TextBlock Text="Schüler:" VerticalAlignment="Center"/>
|
||||||
<ComboBox ItemsSource="{Binding StudentFilterOptions}" SelectedItem="{Binding SelectedStudentFilter}"
|
<ComboBox ItemsSource="{Binding StudentFilterOptions}" SelectedItem="{Binding SelectedStudentFilter}"
|
||||||
@@ -16,7 +16,12 @@
|
|||||||
<CheckBox Grid.Column="1" Content="Nur dieser Unterricht" IsChecked="{Binding OnlyThisGroup}"
|
<CheckBox Grid.Column="1" Content="Nur dieser Unterricht" IsChecked="{Binding OnlyThisGroup}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
ToolTip.Tip="Standardmäßig werden auch Einträge aus anderen Lerngruppen desselben Schülers angezeigt (optisch abgesetzt) — damit Muster über mehrere Fächer/Kurse hinweg sichtbar bleiben."/>
|
ToolTip.Tip="Standardmäßig werden auch Einträge aus anderen Lerngruppen desselben Schülers angezeigt (optisch abgesetzt) — damit Muster über mehrere Fächer/Kurse hinweg sichtbar bleiben."/>
|
||||||
<Button Grid.Column="3" Content="+ Eintrag" Command="{Binding AddDocumentationCommand}"/>
|
<Border Grid.Column="2" Background="#FB8C00" CornerRadius="11" Padding="9,3" Margin="12,0"
|
||||||
|
IsVisible="{Binding !!DraftCount}">
|
||||||
|
<TextBlock Text="{Binding DraftCount, StringFormat='Nacharbeiten: {0}'}" Foreground="White"
|
||||||
|
FontSize="11" FontWeight="SemiBold"/>
|
||||||
|
</Border>
|
||||||
|
<Button Grid.Column="4" Content="+ Eintrag" Command="{Binding AddDocumentationCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<ScrollViewer Grid.Row="1">
|
||||||
@@ -32,6 +37,10 @@
|
|||||||
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" Opacity="0.5" FontSize="12"/>
|
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" Opacity="0.5" FontSize="12"/>
|
||||||
<StackPanel Grid.Column="1" Margin="8,0">
|
<StackPanel Grid.Column="1" Margin="8,0">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Border Background="#FB8C00" CornerRadius="8" Padding="6,1"
|
||||||
|
IsVisible="{Binding IsDraft}">
|
||||||
|
<TextBlock Text="ENTWURF" Foreground="White" FontSize="9" FontWeight="Bold"/>
|
||||||
|
</Border>
|
||||||
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"/>
|
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"/>
|
||||||
<TextBlock Text="{Binding Model.Title}" FontSize="13" Opacity="0.8"
|
<TextBlock Text="{Binding Model.Title}" FontSize="13" Opacity="0.8"
|
||||||
IsVisible="{Binding IsRevealed}"/>
|
IsVisible="{Binding IsRevealed}"/>
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
<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 Orientation="Horizontal" Spacing="8" Margin="0,6,0,0"
|
||||||
|
IsVisible="{Binding IsEditMode, Converter={x:Static BoolConverters.Not}}">
|
||||||
|
<TextBlock Text="Unterricht:" VerticalAlignment="Center" FontSize="12" Opacity="0.65"/>
|
||||||
|
<ComboBox ItemsSource="{Binding TodaySessions}" SelectedItem="{Binding SelectedSession}"
|
||||||
|
DisplayMemberBinding="{Binding DisplayName}" MinWidth="210"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
|
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Spacing="4">
|
||||||
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
|
<ToggleSwitch Content="Bearbeitungsmodus" IsChecked="{Binding IsEditMode}"
|
||||||
@@ -112,7 +118,7 @@
|
|||||||
PointerPressed="OnDragSourcePressed"
|
PointerPressed="OnDragSourcePressed"
|
||||||
PointerMoved="OnDragSourceMoved"
|
PointerMoved="OnDragSourceMoved"
|
||||||
PointerReleased="OnDragSourceReleased"
|
PointerReleased="OnDragSourceReleased"
|
||||||
Tapped="OnSeatTapped">
|
Tapped="OnSeatTapped" Opacity="{Binding LessonOpacity}">
|
||||||
<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"/>
|
||||||
@@ -120,6 +126,33 @@
|
|||||||
</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"/>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
|
||||||
|
IsVisible="{Binding ShowLessonOverview}">
|
||||||
|
<Border Background="#1976D2" CornerRadius="8" Padding="6,1"
|
||||||
|
IsVisible="{Binding HasAttendanceBadge}">
|
||||||
|
<TextBlock Text="{Binding AttendanceBadge}" Foreground="White" FontSize="10"
|
||||||
|
ToolTip.Tip="Anwesenheit"/>
|
||||||
|
</Border>
|
||||||
|
<Border Background="#FB8C00" CornerRadius="8" Padding="6,1"
|
||||||
|
IsVisible="{Binding HasHomeworkBadge}">
|
||||||
|
<TextBlock Text="{Binding HomeworkBadge}" Foreground="White" FontSize="10"
|
||||||
|
ToolTip.Tip="Hausaufgabe"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<Expander Header="+ Situation" FontSize="10"
|
||||||
|
IsVisible="{Binding CanRecordLesson}">
|
||||||
|
<ItemsControl ItemsSource="{Binding SituationTags}" Margin="0,4,0,0">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><WrapPanel ItemSpacing="3" LineSpacing="3"/></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:SituationTagChoice">
|
||||||
|
<ToggleButton Content="{Binding Text}" IsChecked="{Binding IsSelected, Mode=OneWay}"
|
||||||
|
Command="{Binding ToggleCommand}" FontSize="9" Padding="5,2"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Expander>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
@@ -195,6 +197,9 @@ public partial class SeatingPlanTabView : UserControl
|
|||||||
|
|
||||||
private async void OnSeatTapped(object? sender, TappedEventArgs e)
|
private async void OnSeatTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (e.Source is Control source &&
|
||||||
|
(source is Button or Expander || source.FindAncestorOfType<Button>() is not null ||
|
||||||
|
source.FindAncestorOfType<Expander>() is not null)) return;
|
||||||
if (DateTime.UtcNow < _ignoreTapUntil || sender is not Border { DataContext: SeatCellViewModel seat }
|
if (DateTime.UtcNow < _ignoreTapUntil || sender is not Border { DataContext: SeatCellViewModel seat }
|
||||||
|| !seat.IsOccupied || DataContext is not SeatingPlanTabViewModel vm) return;
|
|| !seat.IsOccupied || DataContext is not SeatingPlanTabViewModel vm) return;
|
||||||
await vm.AssessStudent(seat);
|
await vm.AssessStudent(seat);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using LehrerApp.Sync;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views;
|
namespace LehrerApp.Desktop.Views;
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan FinalSyncDelay = TimeSpan.FromMilliseconds(350);
|
||||||
|
private SyncEngine? _syncEngine;
|
||||||
|
private bool _finalSyncStarted;
|
||||||
|
private bool _closeAfterFinalSync;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -14,6 +20,24 @@ public partial class MainWindow : Window
|
|||||||
KeyDown += (_, _) => NotifyActivity();
|
KeyDown += (_, _) => NotifyActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void EnableFinalSync(SyncEngine syncEngine)
|
||||||
|
{
|
||||||
|
_syncEngine = syncEngine;
|
||||||
|
Closing += OnClosing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnClosing(object? sender, WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
if (_closeAfterFinalSync || _finalSyncStarted || _syncEngine is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.Cancel = true;
|
||||||
|
_finalSyncStarted = true;
|
||||||
|
await _syncEngine.SyncBeforeShutdownAsync(FinalSyncDelay);
|
||||||
|
_closeAfterFinalSync = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
private void NotifyActivity()
|
private void NotifyActivity()
|
||||||
{
|
{
|
||||||
if (DataContext is MainWindowViewModel vm) vm.AppLock.NotifyActivity();
|
if (DataContext is MainWindowViewModel vm) vm.AppLock.NotifyActivity();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class SyncEngine : IDisposable
|
|||||||
private readonly SyncConfig _config;
|
private readonly SyncConfig _config;
|
||||||
private readonly AppLogger? _logger;
|
private readonly AppLogger? _logger;
|
||||||
private readonly Timer _timer;
|
private readonly Timer _timer;
|
||||||
|
private readonly SemaphoreSlim _syncGate = new(1, 1);
|
||||||
|
|
||||||
public SyncStatus Status { get; private set; } = new();
|
public SyncStatus Status { get; private set; } = new();
|
||||||
public event Action<SyncStatus>? StatusChanged;
|
public event Action<SyncStatus>? StatusChanged;
|
||||||
@@ -49,8 +50,42 @@ public class SyncEngine : IDisposable
|
|||||||
|
|
||||||
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
||||||
{
|
{
|
||||||
if (Status.State == SyncState.Syncing)
|
if (!await _syncGate.WaitAsync(0))
|
||||||
return new() { Skipped = true, Reason = "Sync bereits aktiv" };
|
return new() { Skipped = true, Reason = "Sync bereits aktiv" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RunSyncAsync(isAutomatic);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wartet kurz auf noch laufende lokale Speicheroperationen und führt anschließend garantiert
|
||||||
|
/// einen Sync aus. Anders als <see cref="SyncNowAsync"/> wird bei einem bereits laufenden Sync
|
||||||
|
/// nicht abgebrochen, sondern gewartet; so bleiben Änderungen, die während dieses Laufs in die
|
||||||
|
/// Outbox gelangen, beim Beenden nicht zurück.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SyncResult> SyncBeforeShutdownAsync(TimeSpan delay)
|
||||||
|
{
|
||||||
|
if (delay > TimeSpan.Zero)
|
||||||
|
await Task.Delay(delay);
|
||||||
|
|
||||||
|
await _syncGate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RunSyncAsync(isAutomatic: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SyncResult> RunSyncAsync(bool isAutomatic)
|
||||||
|
{
|
||||||
SetState(SyncState.Syncing);
|
SetState(SyncState.Syncing);
|
||||||
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
|
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
|
||||||
$"{_queue.PendingCount()} lokal ausstehend");
|
$"{_queue.PendingCount()} lokal ausstehend");
|
||||||
@@ -256,7 +291,7 @@ public class SyncEngine : IDisposable
|
|||||||
StatusChanged?.Invoke(Status);
|
StatusChanged?.Invoke(Status);
|
||||||
}
|
}
|
||||||
private void UpdateStatus() => SetState(Status.State);
|
private void UpdateStatus() => SetState(Status.State);
|
||||||
public void Dispose() { _timer.Dispose(); _queue.Dispose(); }
|
public void Dispose() { _timer.Dispose(); _syncGate.Dispose(); _queue.Dispose(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SyncConfig
|
public class SyncConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user