From 3270b48850dea8c7d133feee2ba90cf2c11e91ef Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Fri, 25 Sep 2026 23:55:54 +0200 Subject: [PATCH] =?UTF-8?q?Add=20student-level=20Vorg=C3=A4nge=20tab=20and?= =?UTF-8?q?=20homework/lateness=20dashboard=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard warnings (attendance, and now homework backlog and lateness) apply across all classes, but the existing Vorgänge case-file tab only works for the teacher's own class since it resolves the WebUntis classbook roster. Instead of loosening that coupling, students get their own narrower Vorgänge tab, and all three cross-class warnings now jump straight there, offering to open a matching case or create a new one. Co-Authored-By: Claude Sonnet 5 --- .../DashboardViewModelTests.cs | 144 +++++++++++- LehrerApp.Desktop.Tests/Fakes.cs | 12 + .../StudentVorgaengeViewModelTests.cs | 141 ++++++++++++ LehrerApp.Desktop/App.axaml.cs | 1 + LehrerApp.Desktop/AppBootstrapper.cs | 1 + LehrerApp.Desktop/ViewModels/AttentionItem.cs | 23 +- .../ClassTeacher/VorgangViewModels.cs | 9 +- .../ViewModels/DashboardViewModel.cs | 119 +++++++++- .../ViewModels/MainWindowViewModel.cs | 16 ++ .../ViewModels/Students/StudentViewModels.cs | 11 +- .../Students/StudentVorgaengeViewModel.cs | 210 ++++++++++++++++++ .../Views/Dashboard/DashboardView.axaml | 2 +- .../Views/Students/StudentDetailView.axaml | 7 +- .../Students/StudentVorgaengeTabView.axaml | 187 ++++++++++++++++ .../Students/StudentVorgaengeTabView.axaml.cs | 77 +++++++ TODO.md | 24 ++ 16 files changed, 963 insertions(+), 21 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/StudentVorgaengeViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 32501c3..494d2f3 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -805,10 +805,152 @@ public sealed class DashboardViewModelTests var item = Assert.Single(Items(vm, AttentionKind.Attendance)); Assert.Equal(student.FullName, item.Title); Assert.Equal("30 %", item.TrailingText); - Assert.True(item.IsAttendanceWarning); + Assert.True(item.IsCrossClassWarning); Assert.Equal(AttentionKind.Attendance, vm.Attention.First().Kind); } + /// Fehlzeiten-Warnungen landen seit der Verknüpfung mit dem schülerbezogenen "Vorgänge"-Tab auf + /// OnNavigateToStudentCase (mit dem passenden Tag "Absentismus") statt auf OnNavigateToStudent. + [Fact] + public void AttendanceWarnings_NavigiertUeberOnNavigateToStudentCaseMitAbsentismusTag() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + for (var i = 0; i < 10; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present; + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status }); + } + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries); + + Guid? navigatedStudentId = null; string? navigatedTag = null; string? navigatedTitle = null; + vm.OnNavigateToStudentCase = (id, tag, title) => { navigatedStudentId = id; navigatedTag = tag; navigatedTitle = title; }; + vm.OnNavigateToStudent = _ => throw new InvalidOperationException("sollte nicht mehr aufgerufen werden"); + + Assert.Single(Items(vm, AttentionKind.Attendance)).Navigate!.Invoke(); + + Assert.Equal(student.Id, navigatedStudentId); + Assert.Equal("Absentismus", navigatedTag); + Assert.Equal("Fehlzeiten", navigatedTitle); + } + + [Fact] + public void MissingHomeworkWarnings_UnterSchwellenwertKeineWarnung() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + + // Nur 2 offene Hausaufgaben — Schwelle liegt bei 3. + for (var i = 0; i < 2; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + entries.Add(new ParticipationEntry + { SessionId = session.Id, StudentId = student.Id, Homework = HomeworkStatus.MissingOpen }); + } + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries); + + Assert.Empty(Items(vm, AttentionKind.MissingHomework)); + } + + [Fact] + public void MissingHomeworkWarnings_MeldetAbSchwellenwertUndZaehltPartiallyCompletedAlsOffen() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + + // 3 offene/überfällige Hausaufgaben (davon eine PartiallyCompleted, zählt laut + // HomeworkDisplay.CountsAsMissing ebenfalls als offen) — erreicht die Schwelle von 3. + var statuses = new[] { HomeworkStatus.MissingOpen, HomeworkStatus.MissingOverdue, HomeworkStatus.PartiallyCompleted }; + for (var i = 0; i < statuses.Length; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Homework = statuses[i] }); + } + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries); + + var item = Assert.Single(Items(vm, AttentionKind.MissingHomework)); + Assert.Equal(student.FullName, item.Title); + Assert.Equal("3 offen", item.TrailingText); + + Guid? navigatedStudentId = null; string? navigatedTag = null; + vm.OnNavigateToStudentCase = (id, tag, _) => { navigatedStudentId = id; navigatedTag = tag; }; + item.Navigate!.Invoke(); + Assert.Equal(student.Id, navigatedStudentId); + Assert.Equal("Hausaufgaben", navigatedTag); + } + + [Fact] + public void LatenessWarnings_UnterSchwellenwertKeineWarnung() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + + // Nur 7 Verspätungen — Schwelle liegt bei 8. + for (var i = 0; i < 7; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late }); + } + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries); + + Assert.Empty(Items(vm, AttentionKind.Lateness)); + } + + /// Regressionsschutz: Late/SignificantlyLate zählen bewusst NICHT in die Fehlzeitenquote hinein + /// (AttendanceBalanceService behandelt sie als Present) — auch wenn ein Schüler die neue + /// Verspätungs-Schwelle reißt, darf die bestehende Fehlzeiten-Warnung dadurch nicht auftauchen. + [Fact] + public void LatenessWarnings_MeldetAbSchwellenwertUndZaehltNichtAlsFehlzeit() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + + // 5x Late, 3x SignificantlyLate — zusammen 8, erreicht die Schwelle. + for (var i = 0; i < 8; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + var status = i < 5 ? AttendanceStatus.Late : AttendanceStatus.SignificantlyLate; + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status }); + } + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries); + + var item = Assert.Single(Items(vm, AttentionKind.Lateness)); + Assert.Equal(student.FullName, item.Title); + Assert.Equal("8× verspätet", item.TrailingText); + Assert.Empty(Items(vm, AttentionKind.Attendance)); + } + [Fact] public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben() { diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 5bae1aa..d95f39e 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -329,6 +329,18 @@ public class FakeDocumentation : IDocumentationRepository public void HardDelete(Guid id) => _all.RemoveAll(d => d.Id == id); } +public class FakeVorgangRepository : IVorgangRepository +{ + private readonly List _all = []; + public void Add(Vorgang v) => _all.Add(v); + public List GetAll() => _all.Where(v => !v.IsDeleted).ToList(); + public List GetByStudent(Guid studentId) => + _all.Where(v => !v.IsDeleted && v.StudentIds.Contains(studentId)).ToList(); + public Vorgang? GetById(Guid id) => _all.FirstOrDefault(v => v.Id == id && !v.IsDeleted); + public void Save(Vorgang vorgang) { _all.RemoveAll(v => v.Id == vorgang.Id); _all.Add(vorgang); } + public void Delete(Guid id) { var v = _all.FirstOrDefault(x => x.Id == id); if (v is not null) v.IsDeleted = true; } +} + public class FakeAttachmentStorage : IAttachmentStorage { private readonly Dictionary _blobs = []; diff --git a/LehrerApp.Desktop.Tests/StudentVorgaengeViewModelTests.cs b/LehrerApp.Desktop.Tests/StudentVorgaengeViewModelTests.cs new file mode 100644 index 0000000..7029bfa --- /dev/null +++ b/LehrerApp.Desktop.Tests/StudentVorgaengeViewModelTests.cs @@ -0,0 +1,141 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für den schülerbezogenen "Vorgänge"-Tab im Schülerdetail (StudentVorgaengeViewModel) — +/// der universelle, klassenübergreifende Einstiegspunkt für die Dashboard-Warnungen (Fehlzeiten, +/// Hausaufgaben-Rückstand, Verspätungen). Anders als ClassTeacherCasesViewModel keine +/// Roster-/WebUntis-Abhängigkeit, deshalb reine In-Memory-Fakes ohne Untis-Cache-Setup. +public sealed class StudentVorgaengeViewModelTests +{ + private static StudentVorgaengeViewModel BuildVm(FakeVorgangRepository? vorgaenge = null, + FakeDocumentation? documentation = null, FakeStudents? students = null) => + new(vorgaenge ?? new FakeVorgangRepository(), documentation ?? new FakeDocumentation(), + students ?? new FakeStudents([])); + + [Fact] + public void Load_ZeigtNurVorgaengeDiesesSchuelers() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var other = new Student { FirstName = "Ben", LastName = "Fremd" }; + var vorgaenge = new FakeVorgangRepository(); + vorgaenge.Add(new Vorgang { Title = "Eigener Vorgang", StudentIds = [student.Id] }); + vorgaenge.Add(new Vorgang { Title = "Fremder Vorgang", StudentIds = [other.Id] }); + + var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student, other])); + vm.Load(student.Id); + + var item = Assert.Single(vm.Cases); + Assert.Equal("Eigener Vorgang", item.Model.Title); + } + + [Fact] + public void Load_OnlyOpenFiltertGeschlosseneVorgaenge() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var vorgaenge = new FakeVorgangRepository(); + vorgaenge.Add(new Vorgang { Title = "Offen", StudentIds = [student.Id], Status = VorgangStatus.Open }); + vorgaenge.Add(new Vorgang { Title = "Geschlossen", StudentIds = [student.Id], Status = VorgangStatus.Closed }); + + var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student])); + vm.Load(student.Id); + + Assert.Equal("Offen", Assert.Single(vm.Cases).Model.Title); + + vm.OnlyOpen = false; + Assert.Equal(2, vm.Cases.Count); + } + + [Fact] + public async Task HandleWarningNavigation_RevealtBestehendenOffenenVorgangMitPassendemTag_OhneRueckfrage() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var vorgaenge = new FakeVorgangRepository(); + vorgaenge.Add(new Vorgang { Title = "Fehlzeiten", StudentIds = [student.Id], Tags = ["Absentismus"] }); + + var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student])); + vm.Load(student.Id); + + var confirmCalled = false; + vm.OnConfirmCreateForWarning = _ => { confirmCalled = true; return Task.FromResult(true); }; + + await vm.HandleWarningNavigation("Absentismus", "Fehlzeiten"); + + Assert.False(confirmCalled); + Assert.True(Assert.Single(vm.Cases).IsRevealed); + } + + [Fact] + public async Task HandleWarningNavigation_IgnoriertGeschlosseneVorgaengeMitPassendemTag() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var vorgaenge = new FakeVorgangRepository(); + vorgaenge.Add(new Vorgang + { Title = "Alt", StudentIds = [student.Id], Tags = ["Absentismus"], Status = VorgangStatus.Closed }); + + var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student])); + vm.Load(student.Id); // OnlyOpen=true (Default) -> der geschlossene Vorgang ist gar nicht in Cases + + var confirmCalled = false; + vm.OnConfirmCreateForWarning = _ => { confirmCalled = true; return Task.FromResult(false); }; + vm.OnEditVorgang = (_, _) => Task.FromResult(null); + + await vm.HandleWarningNavigation("Absentismus", "Fehlzeiten"); + + Assert.True(confirmCalled); + } + + [Fact] + public async Task HandleWarningNavigation_KeinPassenderVorgang_FragtNachUndLegtAnBeiZustimmung() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var vorgaenge = new FakeVorgangRepository(); + var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student])); + vm.Load(student.Id); + + string? confirmedTag = null; + vm.OnConfirmCreateForWarning = tag => { confirmedTag = tag; return Task.FromResult(true); }; + List? passedOptions = null; + Vorgang? passedDraft = null; + vm.OnEditVorgang = (options, editing) => + { + passedOptions = options; + passedDraft = editing; + return Task.FromResult(editing); + }; + + await vm.HandleWarningNavigation("Hausaufgaben", "Hausaufgaben"); + + Assert.Equal("Hausaufgaben", confirmedTag); + Assert.Equal(student.Id, Assert.Single(passedOptions!).Id); + Assert.Equal("Hausaufgaben", passedDraft!.Title); + Assert.Contains("Hausaufgaben", passedDraft.Tags); + Assert.Equal([student.Id], passedDraft.StudentIds); + + var created = Assert.Single(vorgaenge.GetAll()); + Assert.Equal("Hausaufgaben", created.Title); + Assert.True(Assert.Single(vm.Cases).IsRevealed); + } + + [Fact] + public async Task HandleWarningNavigation_KeinPassenderVorgang_LegtNichtsAnBeiAblehnung() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var vorgaenge = new FakeVorgangRepository(); + var vm = BuildVm(vorgaenge: vorgaenge, students: new FakeStudents([student])); + vm.Load(student.Id); + + vm.OnConfirmCreateForWarning = _ => Task.FromResult(false); + var editDialogCalled = false; + vm.OnEditVorgang = (_, _) => { editDialogCalled = true; return Task.FromResult(null); }; + + await vm.HandleWarningNavigation("Verspätungen", "Verspätungen"); + + Assert.False(editDialogCalled); + Assert.Empty(vorgaenge.GetAll()); + Assert.Empty(vm.Cases); + } +} diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 3dbf0d2..a03247b 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -234,6 +234,7 @@ public class App : Application var dash = Services.GetRequiredService(); dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id); dash.OnNavigateToStudent = id => main.NavigateToStudent(id); + dash.OnNavigateToStudentCase = (id, tag, title) => main.NavigateToStudentCase(id, tag, title); dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit" dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index af5a385..54ffc04 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -357,6 +357,7 @@ public static class AppBootstrapper // Transient: neue Instanz pro Navigation (für Detailseiten) services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/AttentionItem.cs b/LehrerApp.Desktop/ViewModels/AttentionItem.cs index 399c41e..adbf831 100644 --- a/LehrerApp.Desktop/ViewModels/AttentionItem.cs +++ b/LehrerApp.Desktop/ViewModels/AttentionItem.cs @@ -3,15 +3,21 @@ using System.Windows.Input; namespace LehrerApp.Desktop.ViewModels; -/// Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten +/// Die neun Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten /// (siehe ), zusammengefasst zu einer Karte mit -/// Filter-Chips statt sieben eigenen Sichtbarkeits-Schaltern. Reihenfolge hier ist die Reihenfolge +/// Filter-Chips statt eigenen Sichtbarkeits-Schaltern je Art. Reihenfolge hier ist die Reihenfolge /// der Gruppen in der zusammengefassten Ansicht (siehe DashboardViewModel.RebuildAttention) — sie /// übernimmt bewusst die frühere Sortierung aus DashboardSettingsService.DefaultCardOrder, damit /// sich nichts, was Nutzer bereits kennen, unvorhersehbar umsortiert. /// "Klausurwochen" (examload) bleibt bewusst außen vor: die Einträge dort sind Wochen-Aggregate, -/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell. -public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan } +/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell. +/// MissingHomework/Lateness (Hausaufgaben-/Verspätungs-Warnung) ergänzen Attendance um dieselbe +/// klassenübergreifende Logik — alle drei navigieren über OnNavigateToStudentCase direkt auf den +/// "Vorgänge"-Tab des Schülerdetails statt auf dessen Standard-Tab (siehe DashboardViewModel). +public enum AttentionKind +{ + MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, MissingHomework, Lateness, SupportPlan, +} /// Eine Inline-Aktion auf einem Handlungsbedarf-Eintrag (z.B. "Entschuldigt"/"Unentschuldigt", /// "Erfassen"). Verwendet ICommand statt Action, damit die bereits vorhandenen generierten @@ -40,8 +46,8 @@ public sealed class AttentionItem /// Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt. public string ProgressLabel { get; } /// Färbt TrailingText wie die frühere Fehlzeiten-Warnung (fest "Foreground=Red") ein — - /// nur bei Attendance gesetzt, weil ein AttentionItem dieser Art per Konstruktion nur entsteht, - /// wenn die Fehlzeitenquote den Schwellenwert bereits überschreitet. + /// bei Attendance/MissingHomework/Lateness gesetzt, weil ein AttentionItem dieser Arten per + /// Konstruktion nur entsteht, wenn der jeweilige Schwellenwert bereits überschritten ist. public bool IsWarningTrailing { get; } public IReadOnlyList Actions { get; } public Action? Navigate { get; } @@ -73,7 +79,10 @@ public sealed class AttentionItem public bool HasSeverity => Severity is not null; public bool IsHighSeverity => Severity == AlertSeverity.High; public bool IsMediumSeverity => Severity == AlertSeverity.Medium; - public bool IsAttendanceWarning => Kind == AttentionKind.Attendance; + /// Färbt den linken Severity-Balken wie die frühere Fehlzeiten-Warnung ein — für alle drei + /// klassenübergreifenden Warnarten (Fehlzeiten/Hausaufgaben/Verspätungen), die per Konstruktion + /// nur entstehen, wenn ihr jeweiliger Schwellenwert bereits überschritten ist. + public bool IsCrossClassWarning => Kind is AttentionKind.Attendance or AttentionKind.MissingHomework or AttentionKind.Lateness; } /// Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs index 7cbf8b2..8c12927 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs @@ -144,8 +144,15 @@ public partial class VorgangDialogViewModel : ObservableObject public VorgangDialogViewModel(List rosterStudents, Vorgang? editing) { _editing = editing; + // Bei genau einem Kandidaten (z.B. der schülerbezogene Vorgänge-Tab im Schülerdetail) und + // einem neuen Vorgang ist ohnehin nur eine Auswahl möglich — Nutzer-Feedback: der sonst + // nötige Extra-Klick auf die einzige Checkbox ist unnötige Reibung. StudentOptions = new ObservableCollection(rosterStudents.Select(s => - new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true })); + new VorgangStudentOption(s.Id, s.Name) + { + IsSelected = editing?.StudentIds.Contains(s.Id) == true + || (editing is null && rosterStudents.Count == 1), + })); if (editing is null) return; Title = editing.Title; diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 804fa9c..cdcffe2 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -4,6 +4,7 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Groups; using System.Collections.ObjectModel; using System.Globalization; @@ -54,6 +55,13 @@ public partial class DashboardViewModel : ObservableObject /// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine /// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize. private const int AttendanceMinSampleSize = 8; + /// Nutzer-Entscheidung: ab 3 offenen/überfälligen Hausaufgaben im laufenden Schuljahr (alle + /// Gruppen) wird gewarnt. Keine Mindeststichprobe wie bei der Fehlzeitenquote nötig — das ist + /// hier ein absoluter Zähler, kein Anteil, der bei wenigen Terminen künstlich hoch ausfallen kann. + private const int MissingHomeworkWarningThreshold = 3; + /// Nutzer-Entscheidung: ab 8 Verspätungen (Late/SignificantlyLate) im laufenden Schuljahr + /// (alle Gruppen) wird gewarnt. + private const int LatenessWarningThreshold = 8; /// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal /// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht /// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren @@ -107,6 +115,8 @@ public partial class DashboardViewModel : ObservableObject private readonly List _excuses = []; private readonly List _attendanceItems = []; + private readonly List _missingHomeworkItems = []; + private readonly List _latenessItems = []; private readonly List _supportItems = []; private readonly List _correctionItems = []; private readonly List _unplannedItems = []; @@ -119,10 +129,13 @@ public partial class DashboardViewModel : ObservableObject private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder = [ (AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"), - // Die Fehlzeitenquote ist auch in fremden Klassen unmittelbar relevant, waehrend offene - // Entschuldigungen nur fuer die eigene Klasse bearbeitet werden koennen. Deshalb steht sie - // im Dashboard vor den Entschuldigungen statt weiter unten zwischen den sonstigen Hinweisen. + // Die Fehlzeitenquote, Hausaufgaben- und Verspätungs-Warnungen sind auch in fremden Klassen + // unmittelbar relevant, waehrend offene Entschuldigungen nur fuer die eigene Klasse bearbeitet + // werden koennen. Deshalb stehen alle drei im Dashboard vor den Entschuldigungen statt weiter + // unten zwischen den sonstigen Hinweisen. (AttentionKind.Attendance, "Fehlzeiten-Warnung"), + (AttentionKind.Lateness, "Verspätungen-Warnung"), + (AttentionKind.MissingHomework, "Hausaufgaben-Warnung"), (AttentionKind.Excuse, "Offene Entschuldigungen"), (AttentionKind.Correction, "Offene Korrekturen"), (AttentionKind.Unplanned, "Ungeplante Stunden"), @@ -133,6 +146,11 @@ public partial class DashboardViewModel : ObservableObject // Navigation-Callback – wird von App.axaml.cs verdrahtet public Action? OnNavigateToGroup { get; set; } public Action? OnNavigateToStudent { get; set; } + // Sprungziel für die drei klassenübergreifenden Warnungen (Fehlzeiten/Hausaufgaben/Verspätungen) + // direkt auf den "Vorgänge"-Tab des Schülerdetails (Guid Schüler, string vorgeschlagenes Tag, + // string vorgeschlagener Titel) — eigenes Delegate statt OnNavigateToStudent zu erweitern, damit + // dessen übrige Aufrufer (Förderplan, Auffälligkeiten, Termine) unverändert bleiben. + public Action? OnNavigateToStudentCase { get; set; } // Sprungziel eigens für eine Stunde in TodaysLessons (9.2) — bewusst getrennt von // OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten // Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt. @@ -160,7 +178,8 @@ public partial class DashboardViewModel : ObservableObject public int OpenTaskCount => OpenTasks.Count; public int UpcomingCount => UpcomingDates.Count; public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count - + _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count; + + _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count + + _missingHomeworkItems.Count + _latenessItems.Count; public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden"; public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben"; public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte"; @@ -286,6 +305,8 @@ public partial class DashboardViewModel : ObservableObject LoadCalendar(); LoadOpenExcuses(groups.Values.ToList(), today); LoadAttendanceWarnings(today); + LoadMissingHomeworkWarnings(today); + LoadLatenessWarnings(today); LoadExamWeekLoads(groups, today); LoadMissingTeachingTime(today); LoadSupportPlanReviews(today); @@ -374,16 +395,19 @@ public partial class DashboardViewModel : ObservableObject // ── Fehlzeiten-Warnung (5.2.3) ──────────────────────────────────────────── + /// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers einzeln + /// GetById aufzurufen (N+1 bei vielen Schülern/Einträgen) — von allen drei klassenübergreifenden + /// Warnungen (Fehlzeiten/Hausaufgaben/Verspätungen) genutzt. + private Dictionary BuildSessionDateLookup() => + _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date); + private void LoadAttendanceWarnings(DateOnly today) { _attendanceItems.Clear(); var schoolYear = _sy.CurrentSchoolYear(); var from = _sy.SchoolYearStart(schoolYear); var to = _sy.SchoolYearEnd(schoolYear); - - // Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers - // einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen). - var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date); + var sessionDates = BuildSessionDateLookup(); var items = new List(); foreach (var student in _students.GetAll()) @@ -404,7 +428,68 @@ public partial class DashboardViewModel : ObservableObject private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new( AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %", - isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId)); + isWarningTrailing: true, + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Absentismus", "Fehlzeiten")); + + // ── Hausaufgaben-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ── + + private void LoadMissingHomeworkWarnings(DateOnly today) + { + _missingHomeworkItems.Clear(); + var schoolYear = _sy.CurrentSchoolYear(); + var from = _sy.SchoolYearStart(schoolYear); + var to = _sy.SchoolYearEnd(schoolYear); + var sessionDates = BuildSessionDateLookup(); + + var items = new List(); + foreach (var student in _students.GetAll()) + { + var count = _participationEntries.GetByStudent(student.Id) + .Count(e => sessionDates.TryGetValue(e.SessionId, out var date) && date >= from && date <= to + && HomeworkDisplay.CountsAsMissing(HomeworkDisplay.Effective(e))); + if (count >= MissingHomeworkWarningThreshold) + items.Add(new MissingHomeworkWarningItem(student.Id, student.FullName, count)); + } + foreach (var item in items.OrderByDescending(i => i.MissingCount)) + _missingHomeworkItems.Add(ToAttentionItem(item)); + } + + private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w) => new( + AttentionKind.MissingHomework, w.StudentName, trailingText: $"{w.MissingCount} offen", + isWarningTrailing: true, + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Hausaufgaben", "Hausaufgaben")); + + // ── Verspätungs-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ── + // + // Late/SignificantlyLate zählen bewusst NICHT in AttendanceBalanceService.Calculate hinein (dort + // gelten sie als Present für die Fehlzeitenquote, siehe Klassenkommentar dort) — diese Zählung + // läuft deshalb unabhängig davon direkt über die rohen ParticipationEntry-Einträge. + + private void LoadLatenessWarnings(DateOnly today) + { + _latenessItems.Clear(); + var schoolYear = _sy.CurrentSchoolYear(); + var from = _sy.SchoolYearStart(schoolYear); + var to = _sy.SchoolYearEnd(schoolYear); + var sessionDates = BuildSessionDateLookup(); + + var items = new List(); + foreach (var student in _students.GetAll()) + { + var count = _participationEntries.GetByStudent(student.Id) + .Count(e => sessionDates.TryGetValue(e.SessionId, out var date) && date >= from && date <= to + && e.Attendance is AttendanceStatus.Late or AttendanceStatus.SignificantlyLate); + if (count >= LatenessWarningThreshold) + items.Add(new LatenessWarningItem(student.Id, student.FullName, count)); + } + foreach (var item in items.OrderByDescending(i => i.LateCount)) + _latenessItems.Add(ToAttentionItem(item)); + } + + private AttentionItem ToAttentionItem(LatenessWarningItem w) => new( + AttentionKind.Lateness, w.StudentName, trailingText: $"{w.LateCount}× verspätet", + isWarningTrailing: true, + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Verspätungen", "Verspätungen")); // ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── // @@ -757,6 +842,8 @@ public partial class DashboardViewModel : ObservableObject [AttentionKind.Unplanned] = _unplannedItems, [AttentionKind.Alert] = _alertItems, [AttentionKind.Attendance] = _attendanceItems, + [AttentionKind.MissingHomework] = _missingHomeworkItems, + [AttentionKind.Lateness] = _latenessItems, [AttentionKind.SupportPlan] = _supportItems, }; @@ -1200,6 +1287,20 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab public double AbsenceRatePercent { get; } = absenceRatePercent; } +public class MissingHomeworkWarningItem(Guid studentId, string studentName, int missingCount) +{ + public Guid StudentId { get; } = studentId; + public string StudentName { get; } = studentName; + public int MissingCount { get; } = missingCount; +} + +public class LatenessWarningItem(Guid studentId, string studentName, int lateCount) +{ + public Guid StudentId { get; } = studentId; + public string StudentName { get; } = studentName; + public int LateCount { get; } = lateCount; +} + public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount) { public int ExamCount { get; } = examCount; diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 394fff2..731d473 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -241,6 +241,22 @@ public partial class MainWindowViewModel : ObservableObject CurrentPage = vm; } + /// Sprungziel aus einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten, Hausaufgaben- + /// Rückstand, Verspätungen) direkt auf den "Vorgänge"-Tab des Schülerdetails. Anders als + /// NavigateToStudent bewusst CurrentPage VOR LoadStudent (wie NavigateToGroupDetail) — die View + /// muss ihre Dialog-Callbacks registriert haben, bevor HandleWarningNavigation ggf. sofort eine + /// Rückfrage/einen Anlege-Dialog auslöst. + public void NavigateToStudentCase(Guid studentId, string suggestedTag, string suggestedTitle) + { + ActiveNavItem = NavItem.Students; + var vm = _services.GetRequiredService(); + vm.OnReturnToStudentList = NavigateToStudents; + vm.ActiveTabIndex = StudentDetailViewModel.VorgaengeTabIndex; + CurrentPage = vm; // View bindet zuerst → Callbacks werden registriert + vm.LoadStudent(studentId); + _ = vm.VorgaengeTab.HandleWarningNavigation(suggestedTag, suggestedTitle); + } + public void NavigateToStudents() => NavigateTo(NavItem.Students); /// Sprungziel aus dem Kurs-Dashboard ("Anstehende Aufgaben für diese Klasse") in den diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index b7dd4fb..9bada05 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -225,6 +225,11 @@ public partial class ManageStudentDialogViewModel : ObservableObject public partial class StudentDetailViewModel : ObservableObject { + /// Tab-Reihenfolge im Schülerdetail (Übersicht=0, Kontakte=1, Noten=2, Dokumentation=3) — für + /// MainWindowViewModel.NavigateToStudentCase, damit die Dashboard-Warnungen (Fehlzeiten, + /// Hausaufgaben, Verspätungen) gezielt hierher statt auf den Standard-Tab springen können. + public const int VorgaengeTabIndex = 4; + private readonly IStudentRepository _students; private readonly IGroupMembershipRepository _memberships; private readonly IGroupRepository _groups; @@ -240,6 +245,7 @@ public partial class StudentDetailViewModel : ObservableObject private readonly SchoolYearService _schoolYear; [ObservableProperty] private Student? _student; + [ObservableProperty] private int _activeTabIndex; [ObservableProperty] private string _studentTitle = ""; [ObservableProperty] private bool _isEditing; [ObservableProperty] private string _editFirstName = ""; @@ -260,6 +266,7 @@ public partial class StudentDetailViewModel : ObservableObject public ObservableCollection Documentation { get; } = []; public ObservableCollection Contacts { get; } = []; public ObservableCollection GradeHistory { get; } = []; + public StudentVorgaengeViewModel VorgaengeTab { get; } public bool HasNoContacts => Contacts.Count == 0; public Func>? OnEditContact { get; set; } public Action? OnViewAddress { get; set; } @@ -278,13 +285,14 @@ public partial class StudentDetailViewModel : ObservableObject IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, IParticipationRepository participation, IParticipationSessionRepository participationSessions, AttendanceBalanceService attendanceBalance, - PersonalDataExportService export, SchoolYearService schoolYear) + PersonalDataExportService export, SchoolYearService schoolYear, StudentVorgaengeViewModel vorgaengeTab) { _students = students; _memberships = memberships; _groups = groups; _subjects = subjects; _docs = docs; _exams = exams; _examResults = examResults; _grades = grades; _participation = participation; _participationSessions = participationSessions; _attendanceBalance = attendanceBalance; _export = export; _schoolYear = schoolYear; + VorgaengeTab = vorgaengeTab; } public void LoadStudent(Guid id) @@ -316,6 +324,7 @@ public partial class StudentDetailViewModel : ObservableObject LoadContacts(); LoadDocumentation(); LoadAttendanceBalance(); + VorgaengeTab.Load(Student.Id); } // ── Dokumentation (5.1) ─────────────────────────────────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs new file mode 100644 index 0000000..1fc2f60 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs @@ -0,0 +1,210 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.ClassTeacher; + +namespace LehrerApp.Desktop.ViewModels.Students; + +/// +/// Schülerbezogener "Vorgänge"-Tab im Schülerdetail — schmalere Variante von +/// ohne Klassen-Roster/WebUntis-Klassenbuch-Kopplung: zeigt +/// und verwaltet nur die Vorgänge dieser einen Person (). +/// Universeller Einstiegspunkt für die klassenübergreifenden Dashboard-Warnungen (Fehlzeiten, +/// Hausaufgaben-Rückstand, Verspätungen) — der Klassenlehrer-"Vorgänge"-Tab bleibt bewusst +/// homeroom-only (siehe TODO.md). Beide teilen sich nur // +/// , keine ViewModel-Logik. +/// +public partial class StudentVorgaengeViewModel : ObservableObject +{ + private readonly IVorgangRepository _vorgaenge; + private readonly IDocumentationRepository _documentation; + private readonly IStudentRepository _students; + + private Guid _studentId; + private string _studentName = ""; + private List _studentOptions = []; + + public ObservableCollection Cases { get; } = []; + + [ObservableProperty] private bool _onlyOpen = true; + + public bool HasCases => Cases.Count > 0; + + public Func, Vorgang?, Task>? OnEditVorgang { get; set; } + public Func>? OnConfirmDeleteVorgang { get; set; } + public Func>? OnEditDocumentation { get; set; } + /// Rückfrage vor dem automatischen Anlegen eines Vorgangs aus einer Dashboard-Warnung heraus + /// (siehe ) — Parameter ist das gesuchte Tag. + public Func>? OnConfirmCreateForWarning { get; set; } + + public StudentVorgaengeViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation, + IStudentRepository students) + { + _vorgaenge = vorgaenge; + _documentation = documentation; + _students = students; + } + + partial void OnOnlyOpenChanged(bool value) => Load(_studentId); + + [RelayCommand] private void Refresh() => Load(_studentId); + + /// Bewusst synchron (anders als ClassTeacherCasesViewModel.LoadInternal) — kein WebUntis-Roster + /// aufzulösen, deshalb kann HandleWarningNavigation direkt im Anschluss laufen, ohne auf einen + /// asynchronen Ladevorgang warten zu müssen. + public void Load(Guid studentId) + { + _studentId = studentId; + _studentName = _students.GetById(studentId)?.FullName ?? ""; + _studentOptions = [new StudentOption(studentId, _studentName)]; + + // IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen + // pro Load) - deshalb hier gemerkt und danach wiederhergestellt (gleiches Muster wie + // ClassTeacherCasesViewModel.LoadInternal). + var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet(); + Cases.Clear(); + + IEnumerable vorgaenge = _vorgaenge.GetByStudent(studentId); + if (OnlyOpen) vorgaenge = vorgaenge.Where(v => v.Status == VorgangStatus.Open); + + // Nur die Dokumentation dieses einen Schülers (nicht GetAll()) — dieses Panel ist die Sicht + // "von diesem Schüler aus", nicht Vorgangsverwaltung für mehrere Beteiligte. + var ownDocs = _documentation.GetByStudent(studentId); + + foreach (var vorgang in vorgaenge.OrderByDescending(v => v.UpdatedAt)) + { + var names = string.Join(", ", vorgang.StudentIds + .Select(id => id == studentId ? _studentName : _students.GetById(id)?.FullName) + .Where(n => !string.IsNullOrEmpty(n))); + var item = new VorgangItem(vorgang, names, LinkDocumentation, UnlinkDocumentation, + RemoveClassRegisterEntry, EditDocumentation) + { IsRevealed = revealedIds.Contains(vorgang.Id) }; + + var linkedIds = vorgang.DocumentationIds.ToHashSet(); + foreach (var doc in ownDocs.Where(d => linkedIds.Contains(d.Id))) + item.LinkedDocumentation.Add(new DocumentationItem(doc, _studentName)); + foreach (var doc in ClassTeacherCasesViewModel.FilterAvailableDocumentation(ownDocs, vorgang)) + item.AvailableDocumentation.Add(new DocumentationItem(doc, _studentName)); + foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date)) + item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry)); + + Cases.Add(item); + } + OnPropertyChanged(nameof(HasCases)); + } + + [RelayCommand] + private async Task AddVorgang() + { + if (OnEditVorgang is null) return; + var result = await OnEditVorgang(_studentOptions, null); + if (result is null) return; + _vorgaenge.Save(result); + Load(_studentId); + } + + [RelayCommand] + private async Task EditVorgang(VorgangItem? item) + { + if (item is null || OnEditVorgang is null) return; + var result = await OnEditVorgang(_studentOptions, item.Model); + if (result is null) return; + _vorgaenge.Save(result); + Load(_studentId); + } + + [RelayCommand] + private void ToggleStatus(VorgangItem? item) + { + if (item is null) return; + item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open; + item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null; + _vorgaenge.Save(item.Model); + Load(_studentId); + } + + [RelayCommand] + private async Task DeleteVorgang(VorgangItem? item) + { + if (item is null) return; + if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return; + _vorgaenge.Delete(item.Model.Id); + Load(_studentId); + } + + [RelayCommand] + private async Task CreateAndLinkDocumentation(VorgangItem? item) + { + if (item is null || OnEditDocumentation is null) return; + var result = await OnEditDocumentation(_studentId, null); + if (result is null) return; + _documentation.Save(result); + item.Model.DocumentationIds.Add(result.Id); + _vorgaenge.Save(item.Model); + Load(_studentId); + } + + private async Task EditDocumentation(DocumentationItem doc) + { + if (OnEditDocumentation is null) return; + var result = await OnEditDocumentation(doc.Model.StudentId, doc.Model); + if (result is null) return; + _documentation.Save(result); + Load(_studentId); + } + + private Task LinkDocumentation(VorgangItem item, DocumentationItem doc) + { + item.Model.DocumentationIds.Add(doc.Model.Id); + _vorgaenge.Save(item.Model); + Load(_studentId); + return Task.CompletedTask; + } + + private Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc) + { + item.Model.DocumentationIds.Remove(doc.Model.Id); + _vorgaenge.Save(item.Model); + Load(_studentId); + return Task.CompletedTask; + } + + private Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row) + { + item.Model.ClassRegisterEntries.Remove(row.Model); + _vorgaenge.Save(item.Model); + Load(_studentId); + return Task.CompletedTask; + } + + /// + /// Von direkt nach dem Laden aufgerufen: + /// sucht einen offenen Vorgang mit passendem Tag (z.B. "Absentismus" für die Fehlzeiten-Warnung) + /// und klappt ihn auf. Findet sich keiner (auch ein geschlossener Vorgang mit passendem Tag zählt + /// hier bewusst nicht als Treffer — er taucht bei OnlyOpen=true gar nicht erst in + /// auf), wird per Rückfrage ein neuer mit vorbelegtem Titel/Tag/Schüler angeboten. + /// + public async Task HandleWarningNavigation(string suggestedTag, string suggestedTitle) + { + var match = Cases.FirstOrDefault(c => + c.Model.Tags.Contains(suggestedTag, StringComparer.OrdinalIgnoreCase)); + if (match is not null) + { + match.IsRevealed = true; + return; + } + + if (OnConfirmCreateForWarning is null || OnEditVorgang is null) return; + if (!await OnConfirmCreateForWarning(suggestedTag)) return; + + var draft = new Vorgang { Title = suggestedTitle, StudentIds = [_studentId], Tags = [suggestedTag] }; + var result = await OnEditVorgang(_studentOptions, draft); + if (result is null) return; + _vorgaenge.Save(result); + Load(_studentId); + var created = Cases.FirstOrDefault(c => c.Model.Id == result.Id); + if (created is not null) created.IsRevealed = true; + } +} diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 82bb904..927a5dc 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -498,7 +498,7 @@ + Classes.attendance="{Binding IsCrossClassWarning}" Margin="0,0,9,0"/> - + @@ -322,6 +323,10 @@ + + + + diff --git a/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml b/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml new file mode 100644 index 0000000..00e85fe --- /dev/null +++ b/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + +