diff --git a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs index 55bb88a..c04ac1d 100644 --- a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs @@ -187,6 +187,60 @@ public sealed class DocumentationDialogViewModelTests Assert.NotEqual("", vm.AttachmentError); } + [Fact] + public void OhneStudentOptions_CanPickStudentIstFalse() + { + var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage()); + + Assert.False(vm.CanPickStudent); + } + + [Fact] + public void MitStudentOptions_OhneAuswahl_SaveSetztFehlerUndSpeichertNicht() + { + var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel"); + var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(), [anna]) + { + Title = "Vorfall", TypeName = "Vorkommnis", + }; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.StudentError); + } + + [Fact] + public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe() + { + var groupId = Guid.NewGuid(); + var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel"); + var ben = new StudentOption(Guid.NewGuid(), "Ben Muster"); + var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(), + [anna, ben], groupId) + { + Title = "Vorfall", TypeName = "Vorkommnis", SelectedStudent = ben, + }; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal(ben.Id, vm.Result!.StudentId); + Assert.Equal(groupId, vm.Result.GroupId); + } + + [Fact] + public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt() + { + var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel"); + var ben = new StudentOption(Guid.NewGuid(), "Ben Muster"); + var existing = new Documentation { StudentId = ben.Id, Title = "Alt" }; + + var vm = new DocumentationDialogViewModel(ben.Id, existing, new FakeAttachmentStorage(), [anna, ben]); + + Assert.Equal(ben, vm.SelectedStudent); + } + [Fact] public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge() { diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs index db17137..3bed1c1 100644 --- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -26,7 +26,8 @@ public sealed class GroupDetailViewModelTests new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), new FakeCompetencyDomains(), students, new CompetencyAnalysisService()), new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships, - new FakeSessions([]), new FakeEntries(), new FakeAspects())); + new FakeSessions([]), new FakeEntries(), new FakeAspects()), + new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups)); vm.LoadGroup(group.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); diff --git a/LehrerApp.Desktop.Tests/GroupDocumentationTabViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDocumentationTabViewModelTests.cs new file mode 100644 index 0000000..cf507ee --- /dev/null +++ b/LehrerApp.Desktop.Tests/GroupDocumentationTabViewModelTests.cs @@ -0,0 +1,147 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für den bisher als Platzhalter existierenden Gruppen-Tab "Dokumentation" (Nutzer-Feedback): +/// listet Dokumentationseinträge aller Gruppen-Schüler, zeigt Einträge aus anderen Lerngruppen +/// standardmäßig mit an (optisch abgesetzt statt versteckt), mit optionalem Filter darauf. +public sealed class GroupDocumentationTabViewModelTests +{ + private static GroupDocumentationTabViewModel BuildVm( + List students, List groups, FakeDocumentation? docs = null) + { + var vm = new GroupDocumentationTabViewModel(docs ?? new FakeDocumentation(), + new FakeStudents(students), new FakeGroups(groups)); + return vm; + } + + [Fact] + public void Initialize_ZeigtEintraegeAllerGruppenschuelerSortiertNachDatumAbsteigend() + { + var group = new LearningGroup { Name = "9c" }; + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var ben = new Student { FirstName = "Ben", LastName = "Muster" }; + var docs = new FakeDocumentation(); + docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Alt", Date = new DateOnly(2025, 9, 1) }); + docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Neu", Date = new DateOnly(2025, 9, 10) }); + + var vm = BuildVm([anna, ben], [group], docs); + vm.Initialize(group.Id); + + Assert.Equal(2, vm.Entries.Count); + Assert.Equal("Neu", vm.Entries[0].Model.Title); + Assert.Equal("Alt", vm.Entries[1].Model.Title); + } + + [Fact] + public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert() + { + var group = new LearningGroup { Name = "9c" }; + var otherGroup = new LearningGroup { Name = "Mathematik 9b" }; + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var docs = new FakeDocumentation(); + docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Aus anderem Kurs", Date = new DateOnly(2025, 9, 1) }); + + var vm = BuildVm([anna], [group, otherGroup], docs); + vm.Initialize(group.Id); + + var entry = Assert.Single(vm.Entries); + Assert.False(entry.IsOwnGroup); + Assert.Equal("Mathematik 9b", entry.OtherGroupLabel); + Assert.True(entry.ContentOpacity < 1.0); + } + + [Fact] + public void Initialize_EintragOhneGruppe_GiltAlsEigen() + { + var group = new LearningGroup { Name = "9c" }; + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var docs = new FakeDocumentation(); + docs.Add(new Documentation { StudentId = anna.Id, GroupId = null, Title = "Allgemein", Date = new DateOnly(2025, 9, 1) }); + + var vm = BuildVm([anna], [group], docs); + vm.Initialize(group.Id); + + var entry = Assert.Single(vm.Entries); + Assert.True(entry.IsOwnGroup); + } + + [Fact] + public void OnlyThisGroup_Aktiviert_BlendetEintraegeAusAnderenGruppenAus() + { + var group = new LearningGroup { Name = "9c" }; + var otherGroup = new LearningGroup { Name = "Mathematik 9b" }; + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var docs = new FakeDocumentation(); + docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Eigen", Date = new DateOnly(2025, 9, 1) }); + docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Fremd", Date = new DateOnly(2025, 9, 2) }); + + var vm = BuildVm([anna], [group, otherGroup], docs); + vm.Initialize(group.Id); + vm.OnlyThisGroup = true; + + var entry = Assert.Single(vm.Entries); + Assert.Equal("Eigen", entry.Model.Title); + } + + [Fact] + public void SelectedStudentFilter_AufEinzelnenSchueler_FiltertListe() + { + var group = new LearningGroup { Name = "9c" }; + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var ben = new Student { FirstName = "Ben", LastName = "Muster" }; + var docs = new FakeDocumentation(); + docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Anna-Eintrag", Date = new DateOnly(2025, 9, 1) }); + docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Ben-Eintrag", Date = new DateOnly(2025, 9, 1) }); + + var vm = BuildVm([anna, ben], [group], docs); + vm.Initialize(group.Id); + vm.SelectedStudentFilter = vm.StudentFilterOptions.Single(s => s.Id == anna.Id); + + var entry = Assert.Single(vm.Entries); + Assert.Equal("Anna-Eintrag", entry.Model.Title); + } + + [Fact] + public async Task AddDocumentation_SpeichertErgebnisUndLaedtNeu() + { + var group = new LearningGroup { Name = "9c" }; + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var docs = new FakeDocumentation(); + var vm = BuildVm([anna], [group], docs); + vm.Initialize(group.Id); + + vm.OnEditDocumentation = (groupId, options, editing) => + { + Assert.Equal(group.Id, groupId); + Assert.Contains(options, o => o.Id == anna.Id); + Assert.Null(editing); + return Task.FromResult(new Documentation + { StudentId = anna.Id, GroupId = groupId, Title = "Neu", Date = new DateOnly(2025, 9, 1) }); + }; + + await vm.AddDocumentationCommand.ExecuteAsync(null); + + var entry = Assert.Single(vm.Entries); + Assert.Equal("Neu", entry.Model.Title); + } + + [Fact] + public async Task DeleteDocumentation_NachBestaetigung_EntferntEintragAusListe() + { + var group = new LearningGroup { Name = "9c" }; + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var docs = new FakeDocumentation(); + docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Weg", Date = new DateOnly(2025, 9, 1) }); + var vm = BuildVm([anna], [group], docs); + vm.Initialize(group.Id); + vm.OnConfirmDeleteDocumentation = _ => Task.FromResult(true); + + await vm.DeleteDocumentationCommand.ExecuteAsync(vm.Entries[0]); + + Assert.Empty(vm.Entries); + } +} diff --git a/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs b/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs index 27d8ebd..b291c71 100644 --- a/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs @@ -27,6 +27,44 @@ public sealed class QuickInputViewModelTests Assert.Equal(1, vm.AspectRows[0].Value); } + [Fact] + public void AnwesenderSchueler_CurrentStudentIsAbsent_IstFalse() + { + var aspects = new List + { + new(new ParticipationAspect { Key = "a", Label = "A" }), + }; + var rows = new List + { + new(Guid.NewGuid(), "Anna", new ParticipationEntry { Attendance = AttendanceStatus.Present }, aspects, []), + }; + var vm = new QuickInputViewModel(rows, aspects); + + Assert.False(vm.CurrentStudentIsAbsent); + Assert.Equal(1.0, vm.CurrentStudentContentOpacity); + } + + [Fact] + public void AbwesenderSchueler_CurrentStudentIsAbsent_IstTrueUndGedimmt() + { + var aspects = new List + { + new(new ParticipationAspect { Key = "a", Label = "A" }), + }; + var rows = new List + { + new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []), + new(Guid.NewGuid(), "Ben", new ParticipationEntry { Attendance = AttendanceStatus.Unexcused }, aspects, []), + }; + var vm = new QuickInputViewModel(rows, aspects); + + vm.NextStudent(); // zu Ben, unentschuldigt abwesend + + Assert.True(vm.CurrentStudentIsAbsent); + Assert.Equal("Krank, unentschuldigt", vm.CurrentStudentAttendanceLabel); + Assert.Equal(0.4, vm.CurrentStudentContentOpacity); + } + [Fact] public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf() { diff --git a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs index 0c9d732..98010ab 100644 --- a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs +++ b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs @@ -15,14 +15,15 @@ public class ReportGradeCalculationTests private static ReportGradeDialogViewModel BuildViewModel( FakeGrades grades, FakeSchemes schemes, FakeReportGrades reportGrades, - FakeExams exams, FakeResults results) + FakeExams exams, FakeResults results, FakeSessions? sessions = null, FakeEntries? entries = null) { var students = new FakeStudents([Anna, Ben]); var memberships = new FakeMemberships([]); var grading = new GradingService(); return new ReportGradeDialogViewModel(grades, exams, results, students, memberships, - schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6, + schemes, reportGrades, sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), + new AttendanceBalanceService(), grading, GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testgruppe", "2025/26"); } @@ -68,6 +69,28 @@ public class ReportGradeCalculationTests Assert.Equal("–", ben.CalculatedDisplay); } + [Fact] + public void AbsenceRatePercent_BerechnetFehlquoteAusSitzungenDieserGruppeImZeitraum() + { + var (_, _, exams, results) = BuildExams(); + var session1 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 5) }; + var session2 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 12) }; + var sessions = new FakeSessions([session1, session2]); + var entries = new FakeEntries(); + entries.Add(new ParticipationEntry { SessionId = session1.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Present }); + entries.Add(new ParticipationEntry { SessionId = session2.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Unexcused }); + + var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), new FakeReportGrades(), exams, results, + sessions, entries); + + var anna = vm.Rows.Single(r => r.StudentId == Anna.Id); + Assert.Equal(50.0, anna.AbsenceRatePercent); + Assert.True(anna.HasHighAbsenceRate); + var ben = vm.Rows.Single(r => r.StudentId == Ben.Id); + Assert.Equal(0.0, ben.AbsenceRatePercent); + Assert.False(ben.HasHighAbsenceRate); + } + [Fact] public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung() { diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index ae1ae0b..efeccbd 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -250,6 +250,7 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupDocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupDocumentationViewModels.cs new file mode 100644 index 0000000..6b33731 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupDocumentationViewModels.cs @@ -0,0 +1,132 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Students; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +/// +/// Gruppen-Tab "Dokumentation" (bisher Platzhalter): zeigt die Dokumentationseinträge aller +/// aktuellen/ehemaligen Schüler dieser Gruppe an einem Ort, statt sie einzeln im Schüler-Tab +/// aufsuchen zu müssen. Nutzer-Feedback: die Liste soll standardmäßig auch Einträge aus anderen +/// Lerngruppen desselben Schülers mit anzeigen (optisch abgesetzt statt ausgeblendet), damit +/// Muster aus anderen Fächern/Kursen nicht verborgen bleiben — ein Schalter blendet sie bei +/// Bedarf ganz aus. +/// +public partial class GroupDocumentationTabViewModel : ObservableObject +{ + private readonly IDocumentationRepository _docs; + private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + + private Guid _groupId; + private List _groupStudents = []; + + public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler"); + + public ObservableCollection Entries { get; } = []; + public ObservableCollection StudentFilterOptions { get; } = [AllStudentsOption]; + + [ObservableProperty] private StudentOption _selectedStudentFilter = AllStudentsOption; + [ObservableProperty] private bool _onlyThisGroup; + + public Func, Documentation?, Task>? OnEditDocumentation { get; set; } + public Func>? OnConfirmDeleteDocumentation { get; set; } + public Func>? OnConductParentCall { get; set; } + + public GroupDocumentationTabViewModel(IDocumentationRepository docs, IStudentRepository students, + IGroupRepository groups) + { + _docs = docs; _students = students; _groups = groups; + } + + partial void OnSelectedStudentFilterChanged(StudentOption value) => Load(); + partial void OnOnlyThisGroupChanged(bool value) => Load(); + + public void Initialize(Guid groupId) + { + _groupId = groupId; + _groupStudents = _students.GetByGroup(groupId) + .Select(s => new StudentOption(s.Id, s.FullName)).ToList(); + + StudentFilterOptions.Clear(); + StudentFilterOptions.Add(AllStudentsOption); + foreach (var s in _groupStudents) StudentFilterOptions.Add(s); + SelectedStudentFilter = AllStudentsOption; + Load(); + } + + private void Load() + { + Entries.Clear(); + if (_groupStudents.Count == 0) return; + + var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name); + var groupNameCache = new Dictionary(); + string GroupLabel(Guid id) + { + if (groupNameCache.TryGetValue(id, out var cached)) return cached; + var label = _groups.GetById(id)?.Name ?? ""; + groupNameCache[id] = label; + return label; + } + + var relevantStudentIds = SelectedStudentFilter.Id == Guid.Empty + ? _groupStudents.Select(s => s.Id) + : [SelectedStudentFilter.Id]; + + var all = relevantStudentIds + .SelectMany(id => _docs.GetByStudent(id)) + .Where(d => !OnlyThisGroup || d.GroupId == _groupId) + .OrderByDescending(d => d.Date); + + foreach (var d in all) + { + var isOwnGroup = d.GroupId is null || d.GroupId == _groupId; + var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value); + Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""), + isOwnGroup, otherGroupLabel)); + } + } + + [RelayCommand] + private async Task AddDocumentation() + { + if (OnEditDocumentation is null || _groupStudents.Count == 0) return; + var result = await OnEditDocumentation(_groupId, _groupStudents, null); + if (result is null) return; + _docs.Save(result); + Load(); + } + + [RelayCommand] + private async Task EditDocumentation(DocumentationItem? item) + { + if (item is null || OnEditDocumentation is null) return; + var result = await OnEditDocumentation(_groupId, _groupStudents, item.Model); + if (result is null) return; + _docs.Save(result); + Load(); + } + + [RelayCommand] + private async Task DeleteDocumentation(DocumentationItem? item) + { + if (item is null) return; + if (OnConfirmDeleteDocumentation is not null && !await OnConfirmDeleteDocumentation(item)) return; + _docs.Delete(item.Model.Id); + Load(); + } + + [RelayCommand] + private async Task ConductParentCall(DocumentationItem? item) + { + if (item is null || OnConductParentCall is null) return; + var result = await OnConductParentCall(item.Model, item.StudentName); + if (result is null) return; + _docs.Save(result); + Load(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index dd6d5db..aee4a25 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -219,6 +219,7 @@ public partial class GroupDetailViewModel : ObservableObject public PlanningTabViewModel PlanningTab { get; } public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; } public SeatingPlanTabViewModel SeatingPlanTab { get; } + public GroupDocumentationTabViewModel GroupDocumentationTab { get; } public Func>? OnAddStudent { get; set; } public Func>? OnWithdrawStudent { get; set; } public Func>? OnAddExam { get; set; } @@ -234,7 +235,7 @@ public partial class GroupDetailViewModel : ObservableObject IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab, - SeatingPlanTabViewModel seatingPlanTab) + SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab) { _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _exams = exams; _grades = grades; _tasks = tasks; @@ -243,6 +244,7 @@ public partial class GroupDetailViewModel : ObservableObject PlanningTab = planningTab; CompetencyOverviewTab = competencyOverviewTab; SeatingPlanTab = seatingPlanTab; + GroupDocumentationTab = groupDocumentationTab; SeatingPlanTab.OnAssessmentChanged = () => { ParticipationTab.LoadSessions(); @@ -268,6 +270,7 @@ public partial class GroupDetailViewModel : ObservableObject PlanningTab.Initialize(Group.Id, IsReadOnly); CompetencyOverviewTab.Initialize(Group); SeatingPlanTab.Initialize(Group.Id, IsReadOnly); + GroupDocumentationTab.Initialize(Group.Id); } private void ReloadExams() diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index d146f91..5b59ed1 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -361,6 +361,9 @@ public partial class ParticipationStudentRow : ObservableObject public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance); public string AttendanceTooltip => AttendanceDisplay.Label(Attendance); + /// 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. + public bool IsAbsent => Attendance is not null and not AttendanceStatus.Present; public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework); public string HomeworkTooltip => HomeworkDisplay.Label(Homework); @@ -442,6 +445,7 @@ public partial class ParticipationStudentRow : ObservableObject }; OnPropertyChanged(nameof(AttendanceLabel)); OnPropertyChanged(nameof(AttendanceTooltip)); + OnPropertyChanged(nameof(IsAbsent)); AttendanceChangedCallback?.Invoke(StudentId, Attendance); } @@ -451,6 +455,7 @@ public partial class ParticipationStudentRow : ObservableObject Attendance = value; OnPropertyChanged(nameof(AttendanceLabel)); OnPropertyChanged(nameof(AttendanceTooltip)); + OnPropertyChanged(nameof(IsAbsent)); AttendanceChangedCallback?.Invoke(StudentId, value); } } @@ -727,6 +732,13 @@ public partial class QuickInputViewModel : ObservableObject [ObservableProperty] private string _currentAspectLabel = ""; [ObservableProperty] private string _currentValueLabel = ""; [ObservableProperty] private string _progressText = ""; + [ObservableProperty] private bool _currentStudentIsAbsent; + [ObservableProperty] private string _currentStudentAttendanceLabel = ""; + + /// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der + /// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein + /// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt. + public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0; public ObservableCollection AspectRows { get; } = []; @@ -758,6 +770,7 @@ public partial class QuickInputViewModel : ObservableObject } partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend)); + partial void OnCurrentStudentIsAbsentChanged(bool value) => OnPropertyChanged(nameof(CurrentStudentContentOpacity)); private AspectValueType CurrentAspectType() => _aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType; @@ -771,6 +784,8 @@ public partial class QuickInputViewModel : ObservableObject var row = _rows[index]; StudentName = row.Name; ProgressText = $"{index + 1} / {_rows.Count}"; + CurrentStudentIsAbsent = row.IsAbsent; + CurrentStudentAttendanceLabel = row.AttendanceTooltip; AspectRows.Clear(); foreach (var (a, i) in _aspects.Select((a, i) => (a, i))) diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs index 9bdfe9d..a98c7c2 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs @@ -20,6 +20,9 @@ public partial class ReportGradeDialogViewModel : ObservableObject private readonly IGroupMembershipRepository _memberships; private readonly IGradingSchemeRepository _schemes; private readonly IReportGradeRepository _reportGrades; + private readonly IParticipationSessionRepository _participationSessions; + private readonly IParticipationRepository _participation; + private readonly AttendanceBalanceService _attendanceBalance; private readonly GradingService _grading; private readonly Guid _groupId; private readonly GroupType _groupType; @@ -51,11 +54,15 @@ public partial class ReportGradeDialogViewModel : ObservableObject public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams, IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships, - IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, GradingService grading, + IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, + IParticipationSessionRepository participationSessions, IParticipationRepository participation, + AttendanceBalanceService attendanceBalance, GradingService grading, Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear) { _grades = grades; _exams = exams; _results = results; _students = students; _memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading; + _participationSessions = participationSessions; _participation = participation; + _attendanceBalance = attendanceBalance; _groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel; _schoolYear = schoolYear; @@ -92,17 +99,36 @@ public partial class ReportGradeDialogViewModel : ObservableObject var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId)); var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList(); + // Fehlquote (Nutzer-Feedback): je Schüler die Anwesenheits-Bilanz im gewählten Zeitraum, + // nur aus Sitzungen dieser Gruppe (anders als StudentDetailViewModel.LoadAttendanceBalance, + // das gruppenübergreifend über den ganzen Schüler rechnet) — hier zählt nur, was für diese + // Zeugnisnote relevant ist. + var sessionsInPeriod = _participationSessions.GetByGroup(_groupId) + .Where(s => s.Date >= periodFrom && s.Date <= periodTo).ToList(); + var attendanceByStudent = new Dictionary>(); + foreach (var session in sessionsInPeriod) + foreach (var entry in _participation.GetBySession(session.Id)) + { + if (!attendanceByStudent.TryGetValue(entry.StudentId, out var list)) + attendanceByStudent[entry.StudentId] = list = []; + list.Add((session.Date, entry.Attendance)); + } + Rows.Clear(); foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) { membershipsByStudent.TryGetValue(student.Id, out var membership); if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue; + var absenceRate = _attendanceBalance.Calculate( + attendanceByStudent.TryGetValue(student.Id, out var entries) ? entries : [], + periodFrom, periodTo).AbsenceRatePercent; + var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag); if (existing is { IsLocked: true }) { - Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, Save, ToggleLock)); + Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, absenceRate, Save, ToggleLock)); continue; } @@ -128,7 +154,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades, scheme, _gradingSystem, RoundingRule); - Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, Save, ToggleLock)); + Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, absenceRate, Save, ToggleLock)); } } @@ -202,10 +228,16 @@ public static class RoundingRuleDisplay public partial class ReportGradeRow : ObservableObject { + /// Nutzer-Vorgabe: ab dieser Fehlquote darf unabhängig von der fachlichen Leistung eine 5 + /// vergeben werden — reine Information/Hervorhebung, kein automatisches Übersteuern der + /// berechneten Note. + public const double HighAbsenceThresholdPercent = 50.0; + public Guid StudentId { get; } public string Name { get; } public string? CalculatedValue { get; private set; } public bool IsLocked { get; private set; } + public double AbsenceRatePercent { get; } [ObservableProperty] private string? _overrideValue; [ObservableProperty] private string? _overrideReason; @@ -214,17 +246,22 @@ public partial class ReportGradeRow : ObservableObject public string CalculatedDisplay => CalculatedValue ?? "–"; public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay; public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben"; + public string AbsenceRateDisplay => $"{AbsenceRatePercent:0.#} % gefehlt"; + public bool HasHighAbsenceRate => AbsenceRatePercent >= HighAbsenceThresholdPercent; + // Rot wie AttendanceDisplay.Color(Truant) — dieselbe Warnfarbe wie im Mitarbeit-Feature. + public string AbsenceRateColorHex => HasHighAbsenceRate ? "#D64545" : "#8A8A8A"; public IRelayCommand SaveCommand { get; } public IRelayCommand ToggleLockCommand { get; } private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing, - bool locked, Action onSave, Action onToggleLock) + double absenceRatePercent, bool locked, Action onSave, Action onToggleLock) { StudentId = studentId; Name = name; CalculatedValue = calculated; IsLocked = locked; + AbsenceRatePercent = absenceRatePercent; _overrideValue = existing?.OverrideValue; _overrideReason = existing?.OverrideReason; SaveCommand = new RelayCommand(() => onSave(this)); @@ -232,12 +269,13 @@ public partial class ReportGradeRow : ObservableObject } public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated, - ReportGrade? existing, Action onSave, Action onToggleLock) => - new(studentId, name, calculated, existing, existing?.IsLocked ?? false, onSave, onToggleLock); + ReportGrade? existing, double absenceRatePercent, + Action onSave, Action onToggleLock) => + new(studentId, name, calculated, existing, absenceRatePercent, existing?.IsLocked ?? false, onSave, onToggleLock); public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked, - Action onSave, Action onToggleLock) => - new(studentId, name, locked.CalculatedValue, locked, true, onSave, onToggleLock); + double absenceRatePercent, Action onSave, Action onToggleLock) => + new(studentId, name, locked.CalculatedValue, locked, absenceRatePercent, true, onSave, onToggleLock); public void MarkSaved() { diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs index 32a0e6f..32cd0b1 100644 --- a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs @@ -4,9 +4,14 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using System.Collections.ObjectModel; using System.Globalization; +using System.Linq; namespace LehrerApp.Desktop.ViewModels.Students; +/// Für die Schüler-Auswahl im Dokumentationsdialog, wenn er ohne festen Schüler geöffnet wird +/// (Gruppen-Tab, siehe GroupDocumentationTabViewModel). +public record StudentOption(Guid Id, string Name); + // ── Dokumentation: deutsche Anzeige für Typ/Status (5.1) ───────────────────── public static class DocumentationTypeDisplay @@ -86,8 +91,17 @@ public partial class DocumentationDialogViewModel : ObservableObject private readonly IAttachmentStorage _attachmentStorage; private readonly Documentation? _editing; private readonly Guid _studentId; + private readonly Guid? _contextGroupId; private readonly List _newlyUploadedStorageIds = []; + // Nur gesetzt, wenn der Dialog aus einem Gruppen-Kontext (5.1, Dokumentation-Tab der + // Lerngruppe) ohne festen Schüler geöffnet wird — vom Schüler-Tab aus (fester _studentId) + // bleibt die Liste leer und die Auswahl unsichtbar. + public List StudentOptions { get; } + public bool CanPickStudent => StudentOptions.Count > 0; + [ObservableProperty] private StudentOption? _selectedStudent; + [ObservableProperty] private string _studentError = ""; + [ObservableProperty] private string _typeName = DocumentationTypeDisplay.Options[0]; [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _title = ""; @@ -145,11 +159,16 @@ public partial class DocumentationDialogViewModel : ObservableObject public Documentation? Result { get; private set; } - public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage) + public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage, + List? studentOptions = null, Guid? contextGroupId = null) { _studentId = studentId; _editing = editing; _attachmentStorage = attachmentStorage; + _contextGroupId = contextGroupId; + StudentOptions = studentOptions ?? []; + if (CanPickStudent) + SelectedStudent = StudentOptions.FirstOrDefault(s => s.Id == studentId); if (editing is null) return; TypeName = DocumentationTypeDisplay.Label(editing.Type); @@ -276,10 +295,12 @@ public partial class DocumentationDialogViewModel : ObservableObject [RelayCommand] private void Save() { - TitleError = ""; DateTextError = ""; ReviewDateTextError = ""; + TitleError = ""; DateTextError = ""; ReviewDateTextError = ""; StudentError = ""; LetterSentDateError = ""; LetterResponseDateError = ""; var valid = true; + if (CanPickStudent && SelectedStudent is null) { StudentError = "Schüler auswählen."; valid = false; } + if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) @@ -312,7 +333,8 @@ public partial class DocumentationDialogViewModel : ObservableObject if (!valid) return; var type = DocumentationTypeDisplay.FromLabel(TypeName); - Result = _editing ?? new Documentation { StudentId = _studentId }; + var effectiveStudentId = CanPickStudent ? SelectedStudent!.Id : _studentId; + Result = _editing ?? new Documentation { StudentId = effectiveStudentId, GroupId = _contextGroupId }; Result.Type = type; Result.Date = date; Result.Title = Title.Trim(); @@ -401,10 +423,21 @@ public partial class DocumentationItem : ObservableObject public bool HasAttachments { get; } public string StatusLabel { get; } public List TagChips { get; } + /// Nur im Gruppen-Tab (5.1, GroupDocumentationTabViewModel) gefüllt — die Schüler-Detailansicht + /// zeigt ohnehin nur Einträge eines einzelnen Schülers und braucht den Namen nicht. + public string StudentName { get; } + /// True, wenn der Eintrag keiner Gruppe zugeordnet ist oder der aktuell angezeigten Gruppe + /// entspricht — false für Einträge aus einem anderen Unterricht desselben Schülers (werden im + /// Gruppen-Tab optisch abgesetzt statt komplett ausgeblendet, siehe Nutzer-Feedback). + public bool IsOwnGroup { get; } + /// Name der Gruppe, aus der ein nicht-eigener Eintrag stammt (nur gesetzt, wenn !IsOwnGroup). + public string OtherGroupLabel { get; } + /// Dimmt Einträge aus anderen Lerngruppen im Gruppen-Tab, ohne sie zu verstecken. + public double ContentOpacity => IsOwnGroup ? 1.0 : 0.55; [ObservableProperty] private bool _isRevealed; - public DocumentationItem(Documentation d) + public DocumentationItem(Documentation d, string studentName = "", bool isOwnGroup = true, string otherGroupLabel = "") { Model = d; DateDisplay = d.Date.ToString("dd.MM.yyyy"); @@ -415,6 +448,9 @@ public partial class DocumentationItem : ObservableObject HasAttachments = d.Attachments.Count > 0; StatusLabel = BuildStatusLabel(d); TagChips = d.Tags.Select(t => new TagChip(t)).ToList(); + StudentName = studentName; + IsOwnGroup = isOwnGroup; + OtherGroupLabel = otherGroupLabel; } private static string BuildStatusLabel(Documentation d) => d.Type switch diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs index f40727d..e9b9e1e 100644 --- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs @@ -66,6 +66,9 @@ public partial class GradeOverviewTabView : UserControl App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), App.Services.GetRequiredService(), _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear); diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml index a5d8057..411a202 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml @@ -205,12 +205,7 @@ - - - - + diff --git a/LehrerApp.Desktop/Views/Groups/GroupDocumentationTabView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDocumentationTabView.axaml new file mode 100644 index 0000000..2f37531 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/GroupDocumentationTabView.axaml @@ -0,0 +1,94 @@ + + + + + + + + + + +