feat: Abwesenheits-Hinweise, Fehlquote bei Zeugnisnoten, Gruppen-Dokumentation

- Schnellbewerten-Dialog: abwesende Schüler werden gedimmt und mit ihrem
  Anwesenheitsstatus statt der Aspektbeschriftung angezeigt, damit keine
  Mitarbeitsnote für nicht anwesende Schüler vergeben wird.
- Zeugnisnoten-Dialog: zeigt je Schüler die Fehlquote im gewählten
  Zeitraum, ab 50 % hervorgehoben (informativ, keine automatische
  Notenänderung).
- Gruppen-Tab "Dokumentation" (bisher Platzhalter) implementiert: listet
  alle Dokumentationseinträge der Gruppen-Schüler, mit Schüler-Filter und
  optionalem "Nur dieser Unterricht"-Schalter. Einträge aus anderen
  Lerngruppen werden standardmäßig mitangezeigt, aber gedimmt. Der
  Dokumentationsdialog bekommt dafür einen optionalen Schüler-Picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:45:01 +02:00
co-authored by Claude Sonnet 5
parent f2cb3c98c6
commit a0f44f1b25
19 changed files with 725 additions and 26 deletions
@@ -187,6 +187,60 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.AttachmentError); 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] [Fact]
public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge() public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge()
{ {
@@ -26,7 +26,8 @@ public sealed class GroupDetailViewModelTests
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()), new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships, 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.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -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<Student> students, List<LearningGroup> 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<Documentation?>(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);
}
}
@@ -27,6 +27,44 @@ public sealed class QuickInputViewModelTests
Assert.Equal(1, vm.AspectRows[0].Value); Assert.Equal(1, vm.AspectRows[0].Value);
} }
[Fact]
public void AnwesenderSchueler_CurrentStudentIsAbsent_IstFalse()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "a", Label = "A" }),
};
var rows = new List<ParticipationStudentRow>
{
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<AspectColumnDef>
{
new(new ParticipationAspect { Key = "a", Label = "A" }),
};
var rows = new List<ParticipationStudentRow>
{
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] [Fact]
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf() public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
{ {
@@ -15,14 +15,15 @@ public class ReportGradeCalculationTests
private static ReportGradeDialogViewModel BuildViewModel( private static ReportGradeDialogViewModel BuildViewModel(
FakeGrades grades, FakeSchemes schemes, FakeReportGrades reportGrades, 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 students = new FakeStudents([Anna, Ben]);
var memberships = new FakeMemberships([]); var memberships = new FakeMemberships([]);
var grading = new GradingService(); var grading = new GradingService();
return new ReportGradeDialogViewModel(grades, exams, results, students, memberships, 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"); "Testgruppe", "2025/26");
} }
@@ -68,6 +69,28 @@ public class ReportGradeCalculationTests
Assert.Equal("", ben.CalculatedDisplay); 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] [Fact]
public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung() public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung()
{ {
+1
View File
@@ -250,6 +250,7 @@ public static class AppBootstrapper
services.AddTransient<PlanningTabViewModel>(); services.AddTransient<PlanningTabViewModel>();
services.AddTransient<CompetencyOverviewTabViewModel>(); services.AddTransient<CompetencyOverviewTabViewModel>();
services.AddTransient<SeatingPlanTabViewModel>(); services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
@@ -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;
/// <summary>
/// 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.
/// </summary>
public partial class GroupDocumentationTabViewModel : ObservableObject
{
private readonly IDocumentationRepository _docs;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private Guid _groupId;
private List<StudentOption> _groupStudents = [];
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
public ObservableCollection<DocumentationItem> Entries { get; } = [];
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
[ObservableProperty] private StudentOption _selectedStudentFilter = AllStudentsOption;
[ObservableProperty] private bool _onlyThisGroup;
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
public Func<Documentation, string, Task<Documentation?>>? 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<Guid, string>();
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();
}
}
@@ -219,6 +219,7 @@ public partial class GroupDetailViewModel : ObservableObject
public PlanningTabViewModel PlanningTab { get; } public PlanningTabViewModel PlanningTab { get; }
public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; } public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; }
public SeatingPlanTabViewModel SeatingPlanTab { get; } public SeatingPlanTabViewModel SeatingPlanTab { get; }
public GroupDocumentationTabViewModel GroupDocumentationTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; } public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; } public Func<StudentSummary, Task<bool>>? OnWithdrawStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; } public Func<Guid, Task<bool>>? OnAddExam { get; set; }
@@ -234,7 +235,7 @@ public partial class GroupDetailViewModel : ObservableObject
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab, PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab,
SeatingPlanTabViewModel seatingPlanTab) SeatingPlanTabViewModel seatingPlanTab, GroupDocumentationTabViewModel groupDocumentationTab)
{ {
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _tasks = tasks; _exams = exams; _grades = grades; _tasks = tasks;
@@ -243,6 +244,7 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab = planningTab; PlanningTab = planningTab;
CompetencyOverviewTab = competencyOverviewTab; CompetencyOverviewTab = competencyOverviewTab;
SeatingPlanTab = seatingPlanTab; SeatingPlanTab = seatingPlanTab;
GroupDocumentationTab = groupDocumentationTab;
SeatingPlanTab.OnAssessmentChanged = () => SeatingPlanTab.OnAssessmentChanged = () =>
{ {
ParticipationTab.LoadSessions(); ParticipationTab.LoadSessions();
@@ -268,6 +270,7 @@ public partial class GroupDetailViewModel : ObservableObject
PlanningTab.Initialize(Group.Id, IsReadOnly); PlanningTab.Initialize(Group.Id, IsReadOnly);
CompetencyOverviewTab.Initialize(Group); CompetencyOverviewTab.Initialize(Group);
SeatingPlanTab.Initialize(Group.Id, IsReadOnly); SeatingPlanTab.Initialize(Group.Id, IsReadOnly);
GroupDocumentationTab.Initialize(Group.Id);
} }
private void ReloadExams() private void ReloadExams()
@@ -361,6 +361,9 @@ public partial class ParticipationStudentRow : ObservableObject
public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance); public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance);
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
/// 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 HomeworkSymbol => HomeworkDisplay.Symbol(Homework);
public string HomeworkTooltip => HomeworkDisplay.Label(Homework); public string HomeworkTooltip => HomeworkDisplay.Label(Homework);
@@ -442,6 +445,7 @@ public partial class ParticipationStudentRow : ObservableObject
}; };
OnPropertyChanged(nameof(AttendanceLabel)); OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip)); OnPropertyChanged(nameof(AttendanceTooltip));
OnPropertyChanged(nameof(IsAbsent));
AttendanceChangedCallback?.Invoke(StudentId, Attendance); AttendanceChangedCallback?.Invoke(StudentId, Attendance);
} }
@@ -451,6 +455,7 @@ public partial class ParticipationStudentRow : ObservableObject
Attendance = value; Attendance = value;
OnPropertyChanged(nameof(AttendanceLabel)); OnPropertyChanged(nameof(AttendanceLabel));
OnPropertyChanged(nameof(AttendanceTooltip)); OnPropertyChanged(nameof(AttendanceTooltip));
OnPropertyChanged(nameof(IsAbsent));
AttendanceChangedCallback?.Invoke(StudentId, value); AttendanceChangedCallback?.Invoke(StudentId, value);
} }
} }
@@ -727,6 +732,13 @@ public partial class QuickInputViewModel : ObservableObject
[ObservableProperty] private string _currentAspectLabel = ""; [ObservableProperty] private string _currentAspectLabel = "";
[ObservableProperty] private string _currentValueLabel = ""; [ObservableProperty] private string _currentValueLabel = "";
[ObservableProperty] private string _progressText = ""; [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<QuickAspectRow> AspectRows { get; } = []; public ObservableCollection<QuickAspectRow> AspectRows { get; } = [];
@@ -758,6 +770,7 @@ public partial class QuickInputViewModel : ObservableObject
} }
partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend)); partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend));
partial void OnCurrentStudentIsAbsentChanged(bool value) => OnPropertyChanged(nameof(CurrentStudentContentOpacity));
private AspectValueType CurrentAspectType() => private AspectValueType CurrentAspectType() =>
_aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType; _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]; var row = _rows[index];
StudentName = row.Name; StudentName = row.Name;
ProgressText = $"{index + 1} / {_rows.Count}"; ProgressText = $"{index + 1} / {_rows.Count}";
CurrentStudentIsAbsent = row.IsAbsent;
CurrentStudentAttendanceLabel = row.AttendanceTooltip;
AspectRows.Clear(); AspectRows.Clear();
foreach (var (a, i) in _aspects.Select((a, i) => (a, i))) foreach (var (a, i) in _aspects.Select((a, i) => (a, i)))
@@ -20,6 +20,9 @@ public partial class ReportGradeDialogViewModel : ObservableObject
private readonly IGroupMembershipRepository _memberships; private readonly IGroupMembershipRepository _memberships;
private readonly IGradingSchemeRepository _schemes; private readonly IGradingSchemeRepository _schemes;
private readonly IReportGradeRepository _reportGrades; private readonly IReportGradeRepository _reportGrades;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly GradingService _grading; private readonly GradingService _grading;
private readonly Guid _groupId; private readonly Guid _groupId;
private readonly GroupType _groupType; private readonly GroupType _groupType;
@@ -51,11 +54,15 @@ public partial class ReportGradeDialogViewModel : ObservableObject
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams, public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships, 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) Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
{ {
_grades = grades; _exams = exams; _results = results; _students = students; _grades = grades; _exams = exams; _results = results; _students = students;
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading; _memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
_participationSessions = participationSessions; _participation = participation;
_attendanceBalance = attendanceBalance;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel; _groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
_schoolYear = schoolYear; _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 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(); 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<Guid, List<(DateOnly Date, AttendanceStatus? Status)>>();
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(); Rows.Clear();
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{ {
membershipsByStudent.TryGetValue(student.Id, out var membership); membershipsByStudent.TryGetValue(student.Id, out var membership);
if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue; 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); var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
if (existing is { IsLocked: true }) 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; continue;
} }
@@ -128,7 +154,7 @@ public partial class ReportGradeDialogViewModel : ObservableObject
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades, var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
scheme, _gradingSystem, RoundingRule); 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 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 Guid StudentId { get; }
public string Name { get; } public string Name { get; }
public string? CalculatedValue { get; private set; } public string? CalculatedValue { get; private set; }
public bool IsLocked { get; private set; } public bool IsLocked { get; private set; }
public double AbsenceRatePercent { get; }
[ObservableProperty] private string? _overrideValue; [ObservableProperty] private string? _overrideValue;
[ObservableProperty] private string? _overrideReason; [ObservableProperty] private string? _overrideReason;
@@ -214,17 +246,22 @@ public partial class ReportGradeRow : ObservableObject
public string CalculatedDisplay => CalculatedValue ?? ""; public string CalculatedDisplay => CalculatedValue ?? "";
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay; public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay;
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben"; 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 SaveCommand { get; }
public IRelayCommand ToggleLockCommand { get; } public IRelayCommand ToggleLockCommand { get; }
private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing, private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing,
bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) double absenceRatePercent, bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
{ {
StudentId = studentId; StudentId = studentId;
Name = name; Name = name;
CalculatedValue = calculated; CalculatedValue = calculated;
IsLocked = locked; IsLocked = locked;
AbsenceRatePercent = absenceRatePercent;
_overrideValue = existing?.OverrideValue; _overrideValue = existing?.OverrideValue;
_overrideReason = existing?.OverrideReason; _overrideReason = existing?.OverrideReason;
SaveCommand = new RelayCommand(() => onSave(this)); SaveCommand = new RelayCommand(() => onSave(this));
@@ -232,12 +269,13 @@ public partial class ReportGradeRow : ObservableObject
} }
public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated, public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated,
ReportGrade? existing, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) => ReportGrade? existing, double absenceRatePercent,
new(studentId, name, calculated, existing, existing?.IsLocked ?? false, onSave, onToggleLock); Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, calculated, existing, absenceRatePercent, existing?.IsLocked ?? false, onSave, onToggleLock);
public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked, public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked,
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) => double absenceRatePercent, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, locked.CalculatedValue, locked, true, onSave, onToggleLock); new(studentId, name, locked.CalculatedValue, locked, absenceRatePercent, true, onSave, onToggleLock);
public void MarkSaved() public void MarkSaved()
{ {
@@ -4,9 +4,14 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq;
namespace LehrerApp.Desktop.ViewModels.Students; 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) ───────────────────── // ── Dokumentation: deutsche Anzeige für Typ/Status (5.1) ─────────────────────
public static class DocumentationTypeDisplay public static class DocumentationTypeDisplay
@@ -86,8 +91,17 @@ public partial class DocumentationDialogViewModel : ObservableObject
private readonly IAttachmentStorage _attachmentStorage; private readonly IAttachmentStorage _attachmentStorage;
private readonly Documentation? _editing; private readonly Documentation? _editing;
private readonly Guid _studentId; private readonly Guid _studentId;
private readonly Guid? _contextGroupId;
private readonly List<string> _newlyUploadedStorageIds = []; private readonly List<string> _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<StudentOption> 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 _typeName = DocumentationTypeDisplay.Options[0];
[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 _title = ""; [ObservableProperty] private string _title = "";
@@ -145,11 +159,16 @@ public partial class DocumentationDialogViewModel : ObservableObject
public Documentation? Result { get; private set; } public Documentation? Result { get; private set; }
public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage) public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage,
List<StudentOption>? studentOptions = null, Guid? contextGroupId = null)
{ {
_studentId = studentId; _studentId = studentId;
_editing = editing; _editing = editing;
_attachmentStorage = attachmentStorage; _attachmentStorage = attachmentStorage;
_contextGroupId = contextGroupId;
StudentOptions = studentOptions ?? [];
if (CanPickStudent)
SelectedStudent = StudentOptions.FirstOrDefault(s => s.Id == studentId);
if (editing is null) return; if (editing is null) return;
TypeName = DocumentationTypeDisplay.Label(editing.Type); TypeName = DocumentationTypeDisplay.Label(editing.Type);
@@ -276,10 +295,12 @@ public partial class DocumentationDialogViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private void Save() private void Save()
{ {
TitleError = ""; DateTextError = ""; ReviewDateTextError = ""; TitleError = ""; DateTextError = ""; ReviewDateTextError = ""; StudentError = "";
LetterSentDateError = ""; LetterResponseDateError = ""; LetterSentDateError = ""; LetterResponseDateError = "";
var valid = true; 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 (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) 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; if (!valid) return;
var type = DocumentationTypeDisplay.FromLabel(TypeName); 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.Type = type;
Result.Date = date; Result.Date = date;
Result.Title = Title.Trim(); Result.Title = Title.Trim();
@@ -401,10 +423,21 @@ public partial class DocumentationItem : ObservableObject
public bool HasAttachments { get; } public bool HasAttachments { 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
/// 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; [ObservableProperty] private bool _isRevealed;
public DocumentationItem(Documentation d) public DocumentationItem(Documentation d, string studentName = "", bool isOwnGroup = true, string otherGroupLabel = "")
{ {
Model = d; Model = d;
DateDisplay = d.Date.ToString("dd.MM.yyyy"); DateDisplay = d.Date.ToString("dd.MM.yyyy");
@@ -415,6 +448,9 @@ public partial class DocumentationItem : ObservableObject
HasAttachments = d.Attachments.Count > 0; HasAttachments = d.Attachments.Count > 0;
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;
IsOwnGroup = isOwnGroup;
OtherGroupLabel = otherGroupLabel;
} }
private static string BuildStatusLabel(Documentation d) => d.Type switch private static string BuildStatusLabel(Documentation d) => d.Type switch
@@ -66,6 +66,9 @@ public partial class GradeOverviewTabView : UserControl
App.Services.GetRequiredService<IGroupMembershipRepository>(), App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGradingSchemeRepository>(), App.Services.GetRequiredService<IGradingSchemeRepository>(),
App.Services.GetRequiredService<IReportGradeRepository>(), App.Services.GetRequiredService<IReportGradeRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<AttendanceBalanceService>(),
App.Services.GetRequiredService<GradingService>(), App.Services.GetRequiredService<GradingService>(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear); _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear);
@@ -205,12 +205,7 @@
<!-- Tab: Dokumentation --> <!-- Tab: Dokumentation -->
<ContentPage Header="Dokumentation"> <ContentPage Header="Dokumentation">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> <views:GroupDocumentationTabView DataContext="{Binding GroupDocumentationTab}"/>
<TextBlock Text="Schülerdokumentation" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage> </ContentPage>
</TabbedPage> </TabbedPage>
@@ -0,0 +1,94 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:svm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Groups.GroupDocumentationTabView"
x:DataType="vm:GroupDocumentationTabViewModel">
<Grid RowDefinitions="Auto,*" Margin="16">
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,12">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="0,0,12,0">
<TextBlock Text="Schüler:" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding StudentFilterOptions}" SelectedItem="{Binding SelectedStudentFilter}"
DisplayMemberBinding="{Binding Name}" MinWidth="170"/>
</StackPanel>
<CheckBox Grid.Column="1" Content="Nur dieser Unterricht" IsChecked="{Binding OnlyThisGroup}"
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."/>
<Button Grid.Column="3" Content=" Eintrag" Command="{Binding AddDocumentationCommand}"/>
</Grid>
<ScrollViewer Grid.Row="1">
<StackPanel>
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:DocumentationItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,10" Margin="0,0,0,8"
Opacity="{Binding ContentOpacity}">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="80,*,Auto,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" Opacity="0.5" FontSize="12"/>
<StackPanel Grid.Column="1" Margin="8,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding Model.Title}" FontSize="13" Opacity="0.8"
IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontSize="13" Opacity="0.6"
IsVisible="{Binding !IsRevealed}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/>
<TextBlock Text="{Binding OtherGroupLabel, StringFormat='aus: {0}'}" FontSize="11"
Opacity="0.5" FontStyle="Italic" IsVisible="{Binding !IsOwnGroup}"/>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Text="🔒" FontSize="14" VerticalAlignment="Center"
IsVisible="{Binding IsConfidential}" ToolTip.Tip="Vertraulich"/>
<Button Grid.Column="3" Content="Anzeigen" FontSize="11" Padding="8,3" Margin="6,0,0,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="4" Margin="6,0,0,0"
IsVisible="{Binding IsRevealed}">
<Button Content="Gespräch begleiten" FontSize="11" Padding="8,3"
IsVisible="{Binding IsParentCall}"
Command="{Binding $parent[ItemsControl].((vm:GroupDocumentationTabViewModel)DataContext).ConductParentCallCommand}"
CommandParameter="{Binding}"/>
<Button Content="Bearbeiten" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:GroupDocumentationTabViewModel)DataContext).EditDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:GroupDocumentationTabViewModel)DataContext).DeleteDocumentationCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding Model.Content}" FontSize="12" TextWrapping="Wrap" Opacity="0.8"
IsVisible="{Binding IsRevealed}"/>
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsRevealed}">
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.55"
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="📎 Anhang" FontSize="11" Opacity="0.55" IsVisible="{Binding HasAttachments}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TagChips}" IsVisible="{Binding IsRevealed}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svm:TagChip">
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2">
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Dokumentation vorhanden." Classes="emptyhint"
IsVisible="{Binding !Entries.Count}"/>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,62 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupDocumentationTabView : UserControl
{
public GroupDocumentationTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is not GroupDocumentationTabViewModel vm) return;
vm.OnEditDocumentation = ShowDocumentationDialog;
vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
vm.OnConductParentCall = ShowParentCallSessionDialog;
}
private async Task<Documentation?> ShowDocumentationDialog(
Guid groupId, List<StudentOption> studentOptions, Documentation? editing)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, groupId);
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
return saved ? vm.Result : null;
}
private async Task<Documentation?> ShowParentCallSessionDialog(Documentation documentation, string studentTitle)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var vm = new ParentCallSessionViewModel(documentation, studentTitle);
var dialog = new ParentCallSessionDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
return saved ? vm.Result : null;
}
private async Task<bool> ShowDeleteDocumentationDialog(DocumentationItem item)
{
var info = new ConfirmDialogInfo
{
Title = "Eintrag löschen?",
Message = $"\"{item.Model.Title}\" ({item.StudentName}) wird als gelöscht markiert und nicht mehr angezeigt.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
}
@@ -12,15 +12,21 @@
<!-- Schülername + Fortschritt --> <!-- Schülername + Fortschritt -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,16"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,16">
<StackPanel Grid.Column="0"> <StackPanel Grid.Column="0">
<TextBlock Text="{Binding StudentName}" FontSize="22" FontWeight="SemiBold"/> <TextBlock Text="{Binding StudentName}" FontSize="22" FontWeight="SemiBold"
<TextBlock Text="{Binding CurrentAspectLabel}" FontSize="13" Opacity="0.5"/> Opacity="{Binding CurrentStudentContentOpacity}"/>
<TextBlock Text="{Binding CurrentAspectLabel}" FontSize="13" Opacity="0.5"
IsVisible="{Binding !CurrentStudentIsAbsent}"/>
<TextBlock Text="{Binding CurrentStudentAttendanceLabel, StringFormat='⚠ Abwesend — {0}'}"
FontSize="13" Foreground="#D96C00" FontWeight="SemiBold"
IsVisible="{Binding CurrentStudentIsAbsent}"/>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ProgressText}" <TextBlock Grid.Column="1" Text="{Binding ProgressText}"
VerticalAlignment="Top" FontSize="13" Opacity="0.4"/> VerticalAlignment="Top" FontSize="13" Opacity="0.4"/>
</Grid> </Grid>
<!-- Aspektliste --> <!-- Aspektliste -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding AspectRows}"> <ItemsControl Grid.Row="1" ItemsSource="{Binding AspectRows}"
Opacity="{Binding CurrentStudentContentOpacity}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:QuickAspectRow"> <DataTemplate x:DataType="vm:QuickAspectRow">
<Grid ColumnDefinitions="4,8,*,44" Margin="0,3"> <Grid ColumnDefinitions="4,8,*,44" Margin="0,3">
@@ -33,7 +33,12 @@
BorderThickness="0,0,0,1" Padding="0,8"> BorderThickness="0,0,0,1" Padding="0,8">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<Grid ColumnDefinitions="180,90,*,120,Auto,Auto"> <Grid ColumnDefinitions="180,90,*,120,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" FontSize="13"/> <StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13"/>
<TextBlock Text="{Binding AbsenceRateDisplay}" FontSize="11"
Foreground="{Binding AbsenceRateColorHex}"
ToolTip.Tip="Fehlquote im gewählten Zeitraum — ab 50 % darf laut Schulrecht unabhängig von der fachlichen Leistung eine 5 vergeben werden."/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding CalculatedDisplay}" VerticalAlignment="Center" <TextBlock Grid.Column="1" Text="{Binding CalculatedDisplay}" VerticalAlignment="Center"
FontSize="13" Opacity="0.7" ToolTip.Tip="Berechnet"/> FontSize="13" Opacity="0.7" ToolTip.Tip="Berechnet"/>
<TextBox Grid.Column="2" Text="{Binding OverrideValue}" PlaceholderText="Übersteuern (optional)" <TextBox Grid.Column="2" Text="{Binding OverrideValue}" PlaceholderText="Übersteuern (optional)"
@@ -12,6 +12,15 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}">
<TextBlock Text="Schüler *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"
PlaceholderText="Schüler wählen"/>
<TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,140"> <Grid ColumnDefinitions="*,12,140">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
+37
View File
@@ -129,6 +129,15 @@ vollständig umgesetzt (siehe unten), ebenso der gleichnamige Tab im Schülerdet
- [x] **2.4.2** Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum). - [x] **2.4.2** Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum).
- [x] **2.4.3** Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin. - [x] **2.4.3** Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin.
- [x] **2.4.4** Export der Zeugnisnotenliste (siehe 11.2). - [x] **2.4.4** Export der Zeugnisnotenliste (siehe 11.2).
- [x] **2.4.5** Fehlquote je Schüler im Zeugnisnoten-Dialog anzeigen (Nutzer-Feedback: "ab 50 %
darf eine 5 gegeben werden"). `ReportGradeDialogViewModel` berechnet je Schüler über
`AttendanceBalanceService.Calculate` die Fehlquote aus den `ParticipationSession`s dieser
Gruppe im gewählten Zeitraum (Halbjahr/Schuljahr) — bewusst nur diese Gruppe, anders als
`StudentDetailViewModel.LoadAttendanceBalance`, das gruppenübergreifend über den ganzen
Schüler rechnet. Anzeige als kleine Zeile unter dem Schülernamen
(`ReportGradeRow.AbsenceRateDisplay`), ab `HighAbsenceThresholdPercent = 50` rot
hervorgehoben (`AbsenceRateColorHex`) mit Tooltip-Hinweis auf die Regel — reine Information,
kein automatisches Übersteuern der berechneten Note.
### 2.5 Notenentwicklung ### 2.5 Notenentwicklung
- [x] **2.5.1** Verlaufsdiagramm pro Schüler über das Schuljahr (im Schülerdetail). - [x] **2.5.1** Verlaufsdiagramm pro Schüler über das Schuljahr (im Schülerdetail).
@@ -216,6 +225,14 @@ Siehe [ParticipationViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/Participa
Bewertungen), aber "Deaktivieren" ist der empfohlene Weg — ein echter Verwendungs-Check vor Bewertungen), aber "Deaktivieren" ist der empfohlene Weg — ein echter Verwendungs-Check vor
dem Löschen (durchsucht alle `ParticipationEntry.Ratings` nach dem Key) ist nicht umgesetzt, dem Löschen (durchsucht alle `ParticipationEntry.Ratings` nach dem Key) ist nicht umgesetzt,
da `AspectRating` nicht nach Aspekt-Key indiziert ist. da `AspectRating` nicht nach Aspekt-Key indiziert ist.
- [x] **3.1.5** Abwesende Schüler im Schnellbewerten-Dialog kenntlich machen (Nutzer-Feedback:
"es macht ja keinen Sinn Schülern eine Mitarbeitsnote zu erteilen, die gar nicht da waren").
`ParticipationStudentRow.IsAbsent` (gesetzte `Attendance`, aber nicht `Present`) treibt
`QuickInputViewModel.CurrentStudentIsAbsent`/`CurrentStudentContentOpacity`: die
Aspektliste wird gedimmt, der Schülername zeigt stattdessen den konkreten
Anwesenheitsstatus ("⚠ Abwesend — Krank, unentschuldigt" o.ä.) in Orange. Bewusst kein
Blockieren der Eingabe (manche Bewertungssysteme erwarten trotzdem einen expliziten
Eintrag) — nur ein visueller Hinweis.
### 3.2 Aggregation zur Mitarbeitsnote ### 3.2 Aggregation zur Mitarbeitsnote
- [x] **3.2.1** Gewichtung je Aspekt konfigurierbar (z.B. Qualität 50 %, Quantität 30 %, Experiment 20 %). - [x] **3.2.1** Gewichtung je Aspekt konfigurierbar (z.B. Qualität 50 %, Quantität 30 %, Experiment 20 %).
@@ -1019,6 +1036,26 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
im UI (nur im Tooltip) — wirkte dadurch wie zufällige/abwechselnde Farbgebung statt wie das im UI (nur im Tooltip) — wirkte dadurch wie zufällige/abwechselnde Farbgebung statt wie das
eigentliche Signal "Auffälligkeit". Jetzt als kleine Legende über dem Diagramm sichtbar, eigentliche Signal "Auffälligkeit". Jetzt als kleine Legende über dem Diagramm sichtbar,
siehe [StudentDetailView.axaml](LehrerApp.Desktop/Views/Students/StudentDetailView.axaml). siehe [StudentDetailView.axaml](LehrerApp.Desktop/Views/Students/StudentDetailView.axaml).
- [x] **5.1.7** Dokumentation je Lerngruppe (Nutzer-Feedback: der "Dokumentation"-Tab der
Lerngruppe war seit seiner Anlage nur ein Platzhalter — "Wird implementiert."). Neuer
[GroupDocumentationTabView](LehrerApp.Desktop/Views/Groups/GroupDocumentationTabView.axaml)/
[GroupDocumentationTabViewModel](LehrerApp.Desktop/ViewModels/Groups/GroupDocumentationViewModels.cs)
listet die Dokumentationseinträge aller (auch ehemaligen) Schüler der Gruppe an einem Ort,
mit Schüler-Filter (Dropdown) und optionalem "Nur dieser Unterricht"-Schalter. Design-
Entscheidung nach Rückfrage: Einträge aus **anderen** Lerngruppen desselben Schülers werden
standardmäßig **mit angezeigt statt versteckt** — nur optisch gedimmt
(`DocumentationItem.ContentOpacity`) und mit `"aus: <Gruppenname>"` beschriftet — damit
Muster über mehrere Fächer/Kurse hinweg nicht verborgen bleiben; der Schalter blendet sie
bei Bedarf ganz aus. Einträge ohne Gruppenbezug (`Documentation.GroupId == null`, z.B. vom
Schüler-Tab aus angelegt) gelten als "eigen" und werden nie gedimmt.
`DocumentationDialogViewModel` bekam dafür einen optionalen Schüler-Picker
(`StudentOptions`/`SelectedStudent`, neuer `StudentOption`-Record): beim Anlegen aus dem
Gruppen-Tab (ohne festen Schüler) ist die Auswahl Pflicht (`StudentError`), neue Einträge
bekommen automatisch die `GroupId` der aktuellen Gruppe gesetzt; der bestehende Aufruf vom
Schüler-Tab (fester `studentId`, keine Optionsliste) bleibt unverändert und zeigt die
Auswahl gar nicht erst an. "Gespräch begleiten" (Elternanruf) und Anhänge funktionieren im
Gruppen-Tab identisch zum Schüler-Tab, da beide dieselbe `DocumentationItem`/
`DocumentationDialog`-Infrastruktur verwenden.
### 5.2 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben) ### 5.2 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben)
- [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über - [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über