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 @@ + + + + + + + + + + + + + + + + +