diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs index 2ef47b0..5a41a93 100644 --- a/LehrerApp.Core/Models/Workload.cs +++ b/LehrerApp.Core/Models/Workload.cs @@ -93,6 +93,12 @@ public class WorkTask public string? Notes { get; set; } // Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4). public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None; + /// Nutzer-Feedback: "Aufgaben" wirkt wie reine Zeiterfassung, dabei braucht man auch schlichte + /// Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse machen"). Reminder-Aufgaben + /// laufen über dieselbe Liste/dasselbe Dashboard-Widget, werden aber nie mit einem + /// TimeEntry verknüpft und fließen deshalb schon rein datenmodell-bedingt (WorkloadEvaluationViewModel + /// wertet nur TimeEntry aus, nicht WorkTask) nicht in die Zeitauswertung (6.3) ein. + public TaskKind Kind { get; set; } = TaskKind.WorkItem; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } @@ -112,3 +118,4 @@ public class TimeEntry public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching } public enum WorkTaskStatus { Open, InProgress, Done } public enum TaskRecurrence { None, Weekly, Monthly } +public enum TaskKind { WorkItem, Reminder } diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index fbbac5f..5f977de 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -6,8 +6,9 @@ using Xunit; namespace LehrerApp.Desktop.Tests; /// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer -/// Stunde). Deckt nur die neue Zeit-/Raum-Auflösung ab, nicht die übrigen, unveränderten -/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...). +/// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der +/// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine +/// (offene Aufgaben, ...). public sealed class DashboardViewModelTests { private static PeriodScheduleService NewPeriodSchedule() @@ -41,7 +42,7 @@ public sealed class DashboardViewModelTests FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, - FakeSessions? sessions = null) + FakeSessions? sessions = null, FakeEntries? entries = null) { lessons ??= new FakeLessons(); lessons.Add(lesson); @@ -49,7 +50,7 @@ public sealed class DashboardViewModelTests new FakeGroups([group]), new FakeSubjects([]), lessons, exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(), reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), - tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), new FakeEntries(), + tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), @@ -243,6 +244,45 @@ public sealed class DashboardViewModelTests e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz"); } + [Fact] + public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag() + { + // Regressionstest (Nutzer-Feedback): "Sitzung erzeugen" (3.3.1) verknüpft die Sitzung über + // LessonId mit der Stunde — dieselbe Unterrichtsstunde darf im Kalender nicht zusätzlich + // als eigener Sitzungstermin auftauchen. + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Brechung" }; + var session = new ParticipationSession { GroupId = group.Id, Date = today, LessonId = lesson.Id, Comment = "Brechung" }; + var vm = BuildVm(group, lesson, sessions: new FakeSessions([session])); + + var day = vm.CalendarDays.Single(d => d.Date == today); + Assert.True(day.HasLesson); + Assert.False(day.HasSession); + + vm.SelectCalendarDayCommand.Execute(day); + + Assert.Single(vm.SelectedDayEvents); + Assert.DoesNotContain(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession); + } + + [Fact] + public void Kalender_EigeneKlasseAmStundenplanTagOhneLessonZeigtDenRingTrotzdem() + { + // Regressionstest (Nutzer-Feedback): der "Meine Klasse"-Ring soll schon laut Stundenplan + // gelten, nicht erst, sobald für den Tag eine Lesson angelegt wurde. + var today = DateOnly.FromDateTime(DateTime.Today); + var tomorrow = today.AddDays(1); + var group = new LearningGroup { Name = "9c", IsOwnClass = true }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = tomorrow.DayOfWeek, PeriodNumber = 3 }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots); + + var day = vm.CalendarDays.Single(d => d.Date == tomorrow); + Assert.True(day.IsOwnClassDay); + } + [Fact] public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert() { @@ -373,4 +413,53 @@ public sealed class DashboardViewModelTests Assert.Empty(vm.UnplannedLessons); } + + [Fact] + public void AttendanceWarnings_KleineStichprobeMitHoherQuoteWirdNichtGemeldet() + { + 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 3 erfasste Termine, alle unentschuldigt (100 %) — Stichprobe zu klein für eine Meldung. + for (var i = 0; i < 3; 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.Unexcused }); + } + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries); + + Assert.Empty(vm.AttendanceWarnings); + } + + [Fact] + public void AttendanceWarnings_MeldetHoheQuoteAbMindeststichprobe() + { + 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(); + + // 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — genug Stichprobe. + 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); + + var item = Assert.Single(vm.AttendanceWarnings); + Assert.Equal(student.FullName, item.StudentName); + Assert.Equal(30.0, item.AbsenceRatePercent); + } } diff --git a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs index 74d2660..1217087 100644 --- a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs @@ -254,4 +254,51 @@ public sealed class GroupOverviewViewModelTests Assert.Equal([6, 4, 3, 8], indices); } + + [Fact] + public void FehlendeHausaufgaben_ListetNurSchuelerAusDerLetztenSitzung() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var groupId = Guid.NewGuid(); + var studentMissing = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var studentDone = new Student { FirstName = "Ben", LastName = "Muster" }; + var students = new FakeStudents([studentMissing, studentDone]); + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + + var olderSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-10) }; + sessions.Save(olderSession); + entries.Add(new ParticipationEntry + { + SessionId = olderSession.Id, StudentId = studentDone.Id, + Homework = HomeworkStatus.MissingOpen, HomeworkMissing = true, + }); + + var lastSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-1) }; + sessions.Save(lastSession); + entries.Add(new ParticipationEntry + { + SessionId = lastSession.Id, StudentId = studentMissing.Id, + Homework = HomeworkStatus.MissingOverdue, HomeworkMissing = true, + }); + entries.Add(new ParticipationEntry + { + SessionId = lastSession.Id, StudentId = studentDone.Id, + Homework = HomeworkStatus.Completed, HomeworkMissing = false, + }); + + var vm = NewVm(sessions: sessions, entries: entries, students: students); + vm.Initialize(groupId, "Testkurs"); + + var item = Assert.Single(vm.MissingHomeworkStudents); + Assert.Equal(studentMissing.FullName, item.StudentName); + Assert.Contains(lastSession.Date.ToString("dd.MM.yyyy"), vm.MissingHomeworkSessionLabel); + } + + [Fact] + public void FehlendeHausaufgaben_OhneOffenePostenIstLeer() + { + var (vm, _, _, _, _, _, _) = BuildScenario(); + Assert.False(vm.HasMissingHomework); + } } diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs index 9146bca..53646fe 100644 --- a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs @@ -284,4 +284,50 @@ public sealed class SeatingPlanViewModelTests Assert.False(vm.CanEdit); Assert.Empty(sessions.GetByGroup(groupId)); } + + [Fact] + public void Initialize_LegtOhneVorhandeneSitzungKeineGeistersitzungAn() + { + // Regressionstest: Initialize() läuft schon beim bloßen Navigieren zur Kursübersicht + // (GroupDetailViewModel.LoadGroup ruft SeatingPlanTab.Initialize für jede Gruppe auf), + // unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Vorher legte + // LoadTodaySessions() dabei sofort eine "Sitzplan"-Sitzung für heute an. + var groupId = Guid.NewGuid(); + var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 }; + var sessions = new FakeSessions([]); + var vm = new SeatingPlanTabViewModel( + new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]), + sessions, new FakeEntries(), new FakeAspects()); + + vm.Initialize(groupId, isReadOnly: false); + + Assert.Empty(sessions.GetByGroup(groupId)); + Assert.Null(vm.SelectedSession); + } + + [Fact] + public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "A" }; + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1, + Assignments = [new SeatAssignment { StudentId = student.Id }], + }; + var sessions = new FakeSessions([]); + var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]), + new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]), + sessions, new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + vm.OnAssessStudent = _ => Task.CompletedTask; + Assert.Empty(sessions.GetByGroup(groupId)); + + await vm.AssessStudent(vm.Seats.Single()); + + var session = Assert.Single(sessions.GetByGroup(groupId)); + Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date); + Assert.Equal("Sitzplan", session.Comment); + Assert.Equal(session.Id, vm.SelectedSession?.Id); + } } diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs index e1b6ce8..ca2c15f 100644 --- a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs @@ -171,7 +171,7 @@ public sealed class WorkTaskListViewModelTests { var tasks = new FakeWorkTasks(); var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries()); - vm.OnEditTask = _ => Task.FromResult(new WorkTask { Title = "Neu" }); + vm.OnEditTask = (_, _) => Task.FromResult(new WorkTask { Title = "Neu" }); await vm.AddTaskCommand.ExecuteAsync(null); @@ -179,6 +179,26 @@ public sealed class WorkTaskListViewModelTests Assert.Equal("Neu", vm.Tasks[0].Title); } + [Fact] + public async Task AddReminder_OeffnetDialogVorbelegtAlsErinnerungUndSpeichertErgebnis() + { + var tasks = new FakeWorkTasks(); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries()); + bool? passedStartAsReminder = null; + vm.OnEditTask = (_, startAsReminder) => + { + passedStartAsReminder = startAsReminder; + return Task.FromResult(new WorkTask { Title = "Ansage machen", Kind = TaskKind.Reminder }); + }; + + await vm.AddReminderCommand.ExecuteAsync(null); + + Assert.True(passedStartAsReminder); + var item = Assert.Single(vm.Tasks); + Assert.True(item.IsReminder); + Assert.Equal("Erinnerung", item.CategoryLabel); + } + [Fact] public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde() { @@ -285,6 +305,38 @@ public sealed class AddEditWorkTaskDialogViewModelTests Assert.NotNull(vm.Result); Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence); } + + [Fact] + public void Save_AlsErinnerung_SetztKindUndVerwirftGeschaetzteDauer() + { + var vm = new AddEditWorkTaskDialogViewModel(null, []) + { + Title = "Ansage an die Klasse machen", + IsReminder = true, + EstimatedMinutesText = "90", // sollte ignoriert werden + }; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal(TaskKind.Reminder, vm.Result!.Kind); + Assert.Null(vm.Result.EstimatedMinutes); + } + + [Fact] + public void Konstruktor_StartAsReminder_SetztIsReminderVorbelegt() + { + var vm = new AddEditWorkTaskDialogViewModel(null, [], startAsReminder: true); + Assert.True(vm.IsReminder); + } + + [Fact] + public void Konstruktor_BestehendeErinnerung_ErkenntKindWieder() + { + var source = new WorkTask { Title = "Alt", Kind = TaskKind.Reminder }; + var vm = new AddEditWorkTaskDialogViewModel(source, []); + Assert.True(vm.IsReminder); + } } public sealed class TimeTrackingViewModelTests diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 20c3277..4d96ca1 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -42,6 +42,11 @@ public partial class DashboardViewModel : ObservableObject /// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug, /// um sich abends noch vorzubereiten. private const int UnplannedLessonsLookaheadDays = 1; + /// Nutzer-Feedback: unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden + /// Schuljahr wird eine hohe Fehlquote NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, + /// 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; [ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _currentDate = ""; @@ -156,7 +161,8 @@ public partial class DashboardViewModel : ObservableObject .OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5)) OpenTasks.Add(new() { Title = t.Title, DueDate = t.DueDate?.ToString("dd.MM.") ?? "", - IsOverdue = t.DueDate.HasValue && t.DueDate < today }); + IsOverdue = t.DueDate.HasValue && t.DueDate < today, + IsReminder = t.Kind == TaskKind.Reminder }); CurrentGroups.Clear(); foreach (var g in groups.Values.OrderBy(g => g.Name)) @@ -197,7 +203,7 @@ public partial class DashboardViewModel : ObservableObject .Select(t => (t.Item1!.Value, t.Attendance)); var balance = _attendanceBalance.Calculate(entries, from, to); - if (balance.ExceedsThreshold) + if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold) items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); } foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) @@ -555,8 +561,12 @@ public partial class DashboardViewModel : ObservableObject exam.Title, g.Name, g.Id)); } + // Sitzungen, die über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstanden sind + // (LessonId gesetzt), bekommen bewusst keinen eigenen Kalendereintrag — die Stunde + // selbst ist an diesem Tag/dieser Gruppe schon als Lesson-Termin gelistet, ein + // zweiter Eintrag für dieselbe Unterrichtsstunde wäre eine Dopplung. foreach (var session in _participationSessions.GetByGroup(g.Id) - .Where(s => s.Date >= gridStart && s.Date <= gridEnd)) + .Where(s => s.LessonId is null && s.Date >= gridStart && s.Date <= gridEnd)) { var agg = Agg(session.Date); agg.HasSession = true; @@ -566,6 +576,35 @@ public partial class DashboardViewModel : ObservableObject } } + // "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung + // existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den + // aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht. + // Dieselbe Projektion wie bei den ungeplanten Stunden (LoadUnplannedLessons), nur über den + // gesamten Kalenderraster statt nur die nächsten Tage. + var publicHolidayDatesForGrid = Enumerable.Range(gridStart.Year, gridEnd.Year - gridStart.Year + 1) + .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) + .Select(h => h.Date).ToHashSet(); + var schoolHolidaysForGrid = _schoolHolidays.GetAll(); + foreach (var g in groups.Where(g => g.IsOwnClass)) + { + var slots = _timetableSlots.GetByGroup(g.Id); + if (slots.Count == 0) continue; + + for (var date = gridStart; date <= gridEnd; date = date.AddDays(1)) + { + if (IsFreeDay(date, schoolHolidaysForGrid, publicHolidayDatesForGrid)) continue; + var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList(); + if (daySlots.Count == 0) continue; + + var cancelledPeriods = _substitutions.GetByDate(date) + .Where(s => s.Kind == SubstitutionKind.Cancelled) + .Select(s => s.PeriodNumber).ToHashSet(); + if (daySlots.All(s => cancelledPeriods.Contains(s.PeriodNumber))) continue; + + Agg(date).IsOwnClassDay = true; + } + } + for (var i = 0; i < 42; i++) { var date = gridStart.AddDays(i); @@ -659,7 +698,7 @@ public class LessonItem public string Room { get; set; } = ""; public bool HasRoom => !string.IsNullOrWhiteSpace(Room); } -public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } } +public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } } public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; } // ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ──────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs index 72df9a3..de5bf4e 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs @@ -25,9 +25,9 @@ public partial class GroupOverviewViewModel : ObservableObject /// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine /// hohe Fehlquote bewusst NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rein /// rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine - /// Stichprobe. Dasselbe Problem hat aktuell noch die Fehlzeiten-Warnung im Hauptdashboard - /// (AttendanceBalanceService/AttendanceWarningItem) — bewusst unangetastet, wird dort separat - /// nachgezogen. + /// Stichprobe. Dieselbe Konstante wie DashboardViewModel.AttendanceMinSampleSize + /// (Hauptdashboard-Fehlzeiten-Warnung, 5.2.3) — dort dasselbe Problem, mittlerweile ebenfalls + /// gefixt. private const int AttendanceMinSampleSize = 8; private readonly ILessonRepository _lessons; @@ -55,6 +55,10 @@ public partial class GroupOverviewViewModel : ObservableObject public ObservableCollection OpenExcuses { get; } = []; public bool HasOpenExcuses => OpenExcuses.Count > 0; + [ObservableProperty] private string _missingHomeworkSessionLabel = ""; + public ObservableCollection MissingHomeworkStudents { get; } = []; + public bool HasMissingHomework => MissingHomeworkStudents.Count > 0; + /// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") — /// siehe . public ObservableCollection AttendanceNotices { get; } = []; @@ -97,6 +101,7 @@ public partial class GroupOverviewViewModel : ObservableObject LoadOpenExcuses(today); LoadDraftDocumentationCount(); LoadAttendanceNotices(today); + LoadMissingHomework(); } private void LoadNextLesson(DateOnly today) @@ -200,10 +205,9 @@ public partial class GroupOverviewViewModel : ObservableObject .Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId)); } - /// Bewusst kein 1:1-Abbild der Dashboard-Fehlzeiten-Warnung: dieselbe Quote - /// (AttendanceBalanceService.WarningThresholdPercent), aber erst ab - /// erfassten Terminen, damit ein Fehltag in der ersten - /// Schulwoche nicht sofort als auffällig gilt. + /// Dieselbe Mindeststichprobe wie DashboardViewModel.LoadAttendanceWarnings + /// (AttendanceMinSampleSize) — erst ab genug erfassten Terminen wird eine hohe Quote gemeldet, + /// damit ein Fehltag in der ersten Schulwoche nicht sofort als auffällig gilt. private void LoadAttendanceNotices(DateOnly today) { AttendanceNotices.Clear(); @@ -225,15 +229,41 @@ public partial class GroupOverviewViewModel : ObservableObject var items = new List(); foreach (var student in _students.GetByGroup(_groupId)) { - if (!entriesByStudent.TryGetValue(student.Id, out var entries) || entries.Count < AttendanceMinSampleSize) - continue; + if (!entriesByStudent.TryGetValue(student.Id, out var entries)) continue; var balance = _attendanceBalance.Calculate(entries, from, to); - if (balance.ExceedsThreshold) + if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold) items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); } foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) AttendanceNotices.Add(item); OnPropertyChanged(nameof(HasAttendanceNotices)); } + + /// Schüler mit nicht gemachter Hausaufgabe aus der letzten Mitarbeitssitzung + /// (ParticipationEntry.HomeworkMissing, gepflegt über HomeworkDisplay.CountsAsMissing) — + /// bewusst nur die letzte Sitzung, nicht die gesamte Historie, analog zur + /// Hausaufgaben-Kontrolle in . + private void LoadMissingHomework() + { + MissingHomeworkStudents.Clear(); + var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault(); + if (last is not null) + { + MissingHomeworkSessionLabel = $"Aus der Sitzung vom {last.Date:dd.MM.yyyy}:"; + foreach (var entry in _entries.GetBySession(last.Id).Where(e => e.HomeworkMissing)) + { + var student = _students.GetById(entry.StudentId); + if (student is not null) + MissingHomeworkStudents.Add(new MissingHomeworkItem(student.FullName, HomeworkDisplay.Label(entry.Homework))); + } + } + OnPropertyChanged(nameof(HasMissingHomework)); + } +} + +public sealed class MissingHomeworkItem(string studentName, string statusLabel) +{ + public string StudentName { get; } = studentName; + public string StatusLabel { get; } = statusLabel; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index 48099c5..a25c2bf 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -76,22 +76,39 @@ public partial class SeatingPlanTabViewModel : ObservableObject NotifyCommands(); } + /// Legt bewusst KEINE Sitzung an, nur weil der Tab geöffnet wird — Initialize() läuft für + /// jede Gruppe schon beim bloßen Navigieren zur Kursübersicht (GroupDetailViewModel.LoadGroup), + /// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Eine Sitzung entsteht erst + /// bei der ersten tatsächlichen Bewertung/Markierung, siehe EnsureTodaySession(). private void LoadTodaySessions() { TodaySessions.Clear(); var today = DateOnly.FromDateTime(DateTime.Today); var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today) .OrderBy(s => s.CreatedAt).ToList(); - if (sessions.Count == 0 && !_isReadOnly) - { - var created = new ParticipationSession { GroupId = _groupId, Date = today, Comment = "Sitzplan" }; - _sessions.Save(created); - sessions.Add(created); - } foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session)); SelectedSession = TodaySessions.LastOrDefault(); } + /// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen + /// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/ + /// ToggleSituationTag), nicht schon beim Öffnen des Tabs. + private ParticipationSessionOption? EnsureTodaySession() + { + if (SelectedSession is not null) return SelectedSession; + if (!IsEditable) return null; + + var created = new ParticipationSession + { + GroupId = _groupId, Date = DateOnly.FromDateTime(DateTime.Today), Comment = "Sitzplan", + }; + _sessions.Save(created); + var option = new ParticipationSessionOption(created); + TodaySessions.Add(option); + SelectedSession = option; + return option; + } + partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData(); private void LoadStudentOptions() @@ -193,19 +210,21 @@ public partial class SeatingPlanTabViewModel : ObservableObject private void ToggleSituationTag(SeatCellViewModel seat, string tag) { - if (!IsEditable || _documentation is null || SelectedSession is null || + if (!IsEditable || _documentation is null || seat.SelectedOption.StudentId is not Guid studentId) return; + var session = EnsureTodaySession(); + if (session is null) return; var draft = _documentation.GetByStudent(studentId) - .FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id); + .FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == session.Id); if (draft is null) { draft = new Documentation { StudentId = studentId, GroupId = _groupId, - ParticipationSessionId = SelectedSession.Id, - LessonId = SelectedSession.LessonId, + ParticipationSessionId = session.Id, + LessonId = session.LessonId, Type = DocumentationType.Incident, - Date = SelectedSession.Date, + Date = session.Date, Title = tag, IsDraft = true, Tags = [tag], @@ -297,9 +316,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject public async Task AssessStudent(SeatCellViewModel seat) { if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return; + var session = EnsureTodaySession(); var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects, _groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable, - SelectedSession?.Id); + session?.Id); await OnAssessStudent(assessment); RefreshSeatLessonData(); OnAssessmentChanged?.Invoke(); diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs index 592ff90..aaf0f6a 100644 --- a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs @@ -97,7 +97,8 @@ public partial class WorkTaskListViewModel : ObservableObject public ObservableCollection Tasks { get; } = []; public string CountSummary => $"{Tasks.Count} Aufgabe(n)"; - public Func>? OnEditTask { get; set; } + /// Zweiter Parameter = Dialog startet vorbelegt als Erinnerung (nur bei AddReminder true). + public Func>? OnEditTask { get; set; } private Dictionary _groupNames = []; @@ -166,7 +167,20 @@ public partial class WorkTaskListViewModel : ObservableObject private async Task AddTask() { if (OnEditTask is null) return; - var result = await OnEditTask(null); + var result = await OnEditTask(null, false); + if (result is null) return; + _tasks.Save(result); + Refresh(); + } + + /// Eigener Einstieg statt eines Umschalters im selben Dialog-Aufruf wie AddTask (6.1.1 + /// Nutzer-Feedback): "Erinnerung" soll sich nicht wie eine Variante der Zeiterfassung anfühlen, + /// sondern wie eine eigene, schnelle Aktion. + [RelayCommand] + private async Task AddReminder() + { + if (OnEditTask is null) return; + var result = await OnEditTask(null, true); if (result is null) return; _tasks.Save(result); Refresh(); @@ -176,7 +190,7 @@ public partial class WorkTaskListViewModel : ObservableObject private async Task EditTask(WorkTaskListItem? item) { if (item is null || OnEditTask is null) return; - var result = await OnEditTask(item.Model); + var result = await OnEditTask(item.Model, false); if (result is null) return; _tasks.Save(result); Refresh(); @@ -203,6 +217,7 @@ public partial class WorkTaskListViewModel : ObservableObject ? dueDate.AddDays(7) : dueDate.AddMonths(1), EstimatedMinutes = item.Model.EstimatedMinutes, Recurrence = item.Model.Recurrence, + Kind = item.Model.Kind, Notes = item.Model.Notes, Status = WorkTaskStatus.Open, }); @@ -224,7 +239,8 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut { public WorkTask Model { get; } = model; public string Title => Model.Title; - public string CategoryLabel => TaskCategoryDisplay.Label(Model.Category); + public bool IsReminder => Model.Kind == TaskKind.Reminder; + public string CategoryLabel => IsReminder ? "Erinnerung" : TaskCategoryDisplay.Label(Model.Category); public string GroupName => groupName ?? ""; public string DueDateDisplay => Model.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? ""; public bool IsOverdue => Model.DueDate is { } d && d < DateOnly.FromDateTime(DateTime.Today) @@ -258,21 +274,26 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject [ObservableProperty] private string _estimatedMinutesText = ""; [ObservableProperty] private string _selectedRecurrence = TaskRecurrenceDisplay.Options[0]; [ObservableProperty] private string _notes = ""; + [ObservableProperty] private bool _isReminder; [ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _dueDateError = ""; [ObservableProperty] private string _estimatedMinutesError = ""; - public string DialogTitle => _source is null ? "Aufgabe anlegen" : "Aufgabe bearbeiten"; + public string DialogTitle => _source is null + ? (IsReminder ? "Erinnerung anlegen" : "Aufgabe anlegen") + : (IsReminder ? "Erinnerung bearbeiten" : "Aufgabe bearbeiten"); public List CategoryOptions { get; } = [.. TaskCategoryDisplay.Options]; public List RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options]; public List Groups { get; } public WorkTask? Result { get; private set; } - public AddEditWorkTaskDialogViewModel(WorkTask? source, List groups) + partial void OnIsReminderChanged(bool value) => OnPropertyChanged(nameof(DialogTitle)); + + public AddEditWorkTaskDialogViewModel(WorkTask? source, List groups, bool startAsReminder = false) { _source = source; Groups = groups; - if (source is null) return; + if (source is null) { IsReminder = startAsReminder; return; } Title = source.Title; SelectedCategory = TaskCategoryDisplay.Label(source.Category); @@ -281,6 +302,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? ""; SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence); Notes = source.Notes ?? ""; + IsReminder = source.Kind == TaskKind.Reminder; } [RelayCommand] @@ -303,8 +325,9 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject if (recurrence != TaskRecurrence.None && dueDate is null) { DueDateError = "Fälligkeitsdatum erforderlich, damit die nächste Instanz geplant werden kann."; valid = false; } + // Erinnerungen haben bewusst keinen Zeitbezug — die Minutenschätzung entfällt. int? estimatedMinutes = null; - if (!string.IsNullOrWhiteSpace(EstimatedMinutesText)) + if (!IsReminder && !string.IsNullOrWhiteSpace(EstimatedMinutesText)) { if (!int.TryParse(EstimatedMinutesText, out var m) || m <= 0) { EstimatedMinutesError = "Ganze Zahl > 0 erwartet."; valid = false; } @@ -322,6 +345,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject DueDate = dueDate, EstimatedMinutes = estimatedMinutes, Recurrence = recurrence, + Kind = IsReminder ? TaskKind.Reminder : TaskKind.WorkItem, Status = _source?.Status ?? WorkTaskStatus.Open, Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(), CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow, diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 3cbff6c..6a71820 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -108,10 +108,13 @@ - - + + - diff --git a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml index 1eddcb8..fe4448e 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml @@ -143,6 +143,28 @@ + + + + + + + + + + + + + + + + +