Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes

Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht
gemachter Hausaufgabe aus der letzten Mitarbeitssitzung.

Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von
reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse
machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard,
läuft nicht in die Zeitauswertung ein.

Drei Bugfixes aus Nutzer-Feedback:
- Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung
  erzeugen" mit einer Mitarbeitssitzung verknüpft war.
- Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei
  echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an.
- Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag
  eine Lesson existierte, statt schon laut Stundenplan.

Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift
(Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante
ausweichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:17:18 +02:00
co-authored by Claude Sonnet 5
parent 4d2a89b14b
commit 52a1d45539
16 changed files with 505 additions and 57 deletions
+7
View File
@@ -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 }
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<WorkTask?>(new WorkTask { Title = "Neu" });
vm.OnEditTask = (_, _) => Task.FromResult<WorkTask?>(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<WorkTask?>(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
@@ -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) ────────────────────────
@@ -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<OpenExcuseItem> OpenExcuses { get; } = [];
public bool HasOpenExcuses => OpenExcuses.Count > 0;
[ObservableProperty] private string _missingHomeworkSessionLabel = "";
public ObservableCollection<MissingHomeworkItem> MissingHomeworkStudents { get; } = [];
public bool HasMissingHomework => MissingHomeworkStudents.Count > 0;
/// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
/// siehe <see cref="AttendanceMinSampleSize"/>.
public ObservableCollection<AttendanceWarningItem> 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
/// <see cref="AttendanceMinSampleSize"/> 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<AttendanceWarningItem>();
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 <see cref="LoadOpenHomeworkCheck"/>.
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;
}
@@ -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();
@@ -97,7 +97,8 @@ public partial class WorkTaskListViewModel : ObservableObject
public ObservableCollection<WorkTaskListItem> Tasks { get; } = [];
public string CountSummary => $"{Tasks.Count} Aufgabe(n)";
public Func<WorkTask?, Task<WorkTask?>>? OnEditTask { get; set; }
/// Zweiter Parameter = Dialog startet vorbelegt als Erinnerung (nur bei AddReminder true).
public Func<WorkTask?, bool, Task<WorkTask?>>? OnEditTask { get; set; }
private Dictionary<Guid, string> _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<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options];
public List<LearningGroup> Groups { get; }
public WorkTask? Result { get; private set; }
public AddEditWorkTaskDialogViewModel(WorkTask? source, List<LearningGroup> groups)
partial void OnIsReminderChanged(bool value) => OnPropertyChanged(nameof(DialogTitle));
public AddEditWorkTaskDialogViewModel(WorkTask? source, List<LearningGroup> 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,
@@ -108,10 +108,13 @@
<ItemsControl ItemsSource="{Binding OpenTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Title}"
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="🔔" FontSize="12" Margin="0,0,4,0"
IsVisible="{Binding IsReminder}"
ToolTip.Tip="Erinnerung — kein Zeitbezug"/>
<TextBlock Grid.Column="1" Text="{Binding Title}"
FontSize="13" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding DueDate}"
<TextBlock Grid.Column="2" Text="{Binding DueDate}"
FontSize="12" Opacity="0.6" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
@@ -143,6 +143,28 @@
</ItemsControl>
</StackPanel>
</Border>
<!-- Fehlende Hausaufgaben aus der letzten Sitzung -->
<Border Classes="card" MinWidth="340" MaxWidth="420"
IsVisible="{Binding HasMissingHomework}">
<StackPanel>
<TextBlock Text="FEHLENDE HAUSAUFGABEN" Classes="cardTitle"/>
<TextBlock Text="{Binding MissingHomeworkSessionLabel}" FontSize="11" Opacity="0.6" Margin="0,-6,0,6"/>
<ItemsControl ItemsSource="{Binding MissingHomeworkStudents}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MissingHomeworkItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding StudentName}" FontSize="13"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding StatusLabel}" FontSize="11"
Opacity="0.7" VerticalAlignment="Center" TextAlignment="Right"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Zur Mitarbeit " Classes="cardLink" Command="{Binding NavigateToParticipationCommand}"/>
</StackPanel>
</Border>
</WrapPanel>
</StackPanel>
</ScrollViewer>
+5 -1
View File
@@ -67,10 +67,14 @@
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
<DockPanel.Styles>
<!-- Basisgröße der Icons als Style (nicht lokal), damit der Compact-Selector sie
überschreiben kann lokale Werte hätten immer Vorrang vor Style-Settern. -->
überschreiben kann lokale Werte hätten immer Vorrang vor Style-Settern.
FontFamily explizit auf die farbige Emoji-Schrift gepinnt: Windows kann für
Emoji-Codepoints je nach Fallback-Auflösung sonst auf "Segoe UI Symbol"
(einfarbig/schwarz) statt "Segoe UI Emoji" (farbig) ausweichen. -->
<Style Selector="TextBlock.navicon">
<Setter Property="FontSize" Value="15"/>
<Setter Property="Width" Value="24"/>
<Setter Property="FontFamily" Value="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"/>
</Style>
<Style Selector="Button.navitem">
<Setter Property="Background" Value="Transparent"/>
@@ -20,6 +20,9 @@
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<CheckBox Content="🔔 Nur Erinnerung (kein Zeitbezug, läuft nicht in die Zeiterfassung ein)"
IsChecked="{Binding IsReminder}"/>
<Grid ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Kategorie *" FontSize="12" Opacity="0.7"/>
@@ -46,7 +49,7 @@
<TextBlock Text="{Binding DueDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DueDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<StackPanel Grid.Column="2" Spacing="4" IsVisible="{Binding !IsReminder}">
<TextBlock Text="Geschätzte Dauer in min (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding EstimatedMinutesText}" PlaceholderText="z.B. 90"/>
<TextBlock Text="{Binding EstimatedMinutesError}" Foreground="Red" FontSize="11"
@@ -11,7 +11,11 @@
SelectedItem="{Binding CategoryFilter}" Margin="0,0,8,0"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding GroupFilterOptions}"
SelectedItem="{Binding GroupFilter}"/>
<Button Grid.Column="4" Content=" Neue Aufgabe" Command="{Binding AddTaskCommand}"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="8">
<Button Content="🔔 Erinnerung" Command="{Binding AddReminderCommand}"
ToolTip.Tip="Kurze Notiz ohne Zeitbezug, z.B. 'morgen Ansage an die Klasse machen' — läuft nicht in die Zeiterfassung/Auswertung ein."/>
<Button Content=" Neue Aufgabe" Command="{Binding AddTaskCommand}"/>
</StackPanel>
</Grid>
<ScrollViewer Grid.Row="1" Margin="16,10,16,16">
@@ -30,7 +34,11 @@
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/>
</Button>
<StackPanel Grid.Column="1" Margin="10,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="🔔" FontSize="12" IsVisible="{Binding IsReminder}"
ToolTip.Tip="Erinnerung — kein Zeitbezug, nicht Teil der Auswertung"/>
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/>
</StackPanel>
<TextBlock FontSize="11" Opacity="0.6">
<Run Text="{Binding CategoryLabel}"/>
<Run Text=" · "/>
@@ -17,13 +17,13 @@ public partial class WorkTaskListView : UserControl
vm.OnEditTask = ShowEditDialog;
}
private async Task<WorkTask?> ShowEditDialog(WorkTask? source)
private async Task<WorkTask?> ShowEditDialog(WorkTask? source, bool startAsReminder)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll(includeInactive: true);
var vm = new AddEditWorkTaskDialogViewModel(source, groups);
var vm = new AddEditWorkTaskDialogViewModel(source, groups, startAsReminder);
var dialog = new AddEditWorkTaskDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner);
return vm.Result;
+63 -9
View File
@@ -1112,7 +1112,16 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
(`OtherSchoolEvent`) zählt bewusst nicht als Fehlzeit des Schülers.
- [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im
Schülerdetail sowie eine neue "Fehlzeiten-Warnung"-Karte im Dashboard (alle Schüler über
dem Schwellenwert, sortiert nach Fehlquote).
dem Schwellenwert, sortiert nach Fehlquote). **Nachtrag (Nutzer-Feedback):** die
Dashboard-Karte meldete anfangs auch Schüler mit nur ein oder zwei erfassten Terminen — wer
zu Schuljahresbeginn einmal fehlt, steht damit rechnerisch schon bei 100 %, obwohl das noch
kein auffälliges Muster ist, nur eine zu kleine Stichprobe. `DashboardViewModel.LoadAttendanceWarnings`
meldet jetzt erst ab `AttendanceMinSampleSize` (8) im laufenden Schuljahr erfassten Terminen
(`AttendanceBalance.TotalChecked`) — dieselbe Konstante und Prüfung wie in der
Kurs-Übersicht (14.12, `GroupOverviewViewModel`), an der das zuerst aufgefallen ist.
`AttendanceBalanceService` selbst bleibt unverändert (liefert weiterhin die reine Quote ohne
Mindeststichprobe, z.B. für die Anzeige im Schülerdetail) — die Gate-Prüfung liegt bewusst
nur bei den beiden "Warnung"-Konsumenten.
- [x] **5.2.4** Nachträgliches Entschuldigen mit Frist-Hinweis — bereits vorhanden über die
"Offene Entschuldigungen"-Karte im Dashboard (21-Tage-Grenze, aus Kapitel 3).
@@ -1177,6 +1186,17 @@ CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus
auf `Conducted` (Anbindung an 1.1.3) — Hook in `GroupDetailViewModel.SetExamStatus`, feuert
nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten
Klick), Fälligkeit 14 Tage nach dem Klausurtermin, Kategorie `Correction`.
- [x] **6.1.6** "Erinnerung" als eigene Aufgabenart (Nutzer-Feedback): "Aufgaben" wirkte wie reine
Zeiterfassung, dabei braucht es auch schlichte Reminder ohne Zeitbezug (z.B. "morgen Ansage
an die Klasse machen"). Neues `WorkTask.Kind` (`TaskKind`: `WorkItem`/`Reminder`, Default
`WorkItem` — kein Migrationsschritt nötig, fehlendes BSON-Feld deserialisiert auf den
Enum-Default). Eigener Button "🔔 Erinnerung" neben " Neue Aufgabe"
(`WorkTaskListViewModel.AddReminderCommand`) öffnet denselben `AddEditWorkTaskDialog`, aber
mit der Checkbox "Nur Erinnerung" vorbelegt — dadurch entfällt automatisch das
"Geschätzte Dauer"-Feld (Erinnerungen bekommen nie einen Zeitwert). Läuft bewusst nicht in
die Zeitauswertung (6.3) ein — die bereits rein datenmodell-bedingt, da `WorkloadEvaluationViewModel`
nur tatsächlich erfasste `TimeEntry`-Zeilen auswertet, keine `WorkTask`-Metadaten. In der
Aufgabenliste und im Dashboard-Widget "Offene Aufgaben" durch ein 🔔-Symbol gekennzeichnet.
### 6.2 Zeiterfassung
- [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie —
@@ -1272,6 +1292,14 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
Mitarbeitsaspekte, Anwesenheit und Hausaufgaben. Die Eingabe unterstützt Maus sowie
Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert,
wird einmalig eine Sitzung „Sitzplan“ für heute angelegt.
**Bugfix (Nutzer-Feedback, "Geistereinträge"):** Diese Sitzung entstand ursprünglich schon
beim bloßen Initialisieren des Sitzplan-Tabs (`SeatingPlanTabViewModel.LoadTodaySessions`)
— und `GroupDetailViewModel.LoadGroup` initialisiert alle Tabs einer Gruppe sofort beim
Navigieren dorthin, unabhängig davon, welcher Tab tatsächlich angesehen wird. Ergebnis:
praktisch jede an dem Tag geöffnete Gruppe bekam eine leere "Sitzplan"-Sitzung, auch wenn nie
tatsächlich bewertet wurde. `LoadTodaySessions()` legt jetzt keine Sitzung mehr an; die
Erzeugung passiert erst über `EnsureTodaySession()` bei der ersten tatsächlichen Aktion
(Sitzplatz-Bewertung oder Situations-Tag).
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
@@ -1366,6 +1394,18 @@ Basis vorhanden in [DashboardViewModel.cs](LehrerApp.Desktop/ViewModels/Dashboar
Kleiner Monatskalender ist bereits umgesetzt (Farbcodierung Unterricht/Klausur,
Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreite/-position).
**Zwei Bugfixes am Kalender (Nutzer-Feedback):**
- Eine über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstandene `ParticipationSession`
(`LessonId` gesetzt) erzeugte einen zweiten, redundanten Kalendereintrag neben der Stunde selbst
— dieselbe Unterrichtsstunde stand doppelt in der Tagesliste. `LoadCalendar()` überspringt
jetzt Sitzungen mit gesetzter `LessonId` (die zugehörige Lesson deckt den Tag bereits ab).
- Der "Meine Klasse"-Ring war nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
existierte — ein Tag, an dem laut Stundenplan (4.3) die eigene Klasse ansteht, aber noch keine
Lesson angelegt wurde (typischerweise "morgen"), zeigte den Ring fälschlich nicht. `LoadCalendar()`
projiziert `IsOwnClassDay` jetzt zusätzlich aus dem Stundenplan (`TimetableSlot`, abzüglich
Ferien/Feiertage und `SubstitutionKind.Cancelled`) — dieselbe Projektion wie bei den "ungeplanten
Stunden" (`LoadUnplannedLessons`), nur über den ganzen Kalenderraster statt nur die nächsten Tage.
- [x] **9.1** Heutige Stunden mit Uhrzeit und Raum anzeigen (Abhängigkeit zu 4.3, jetzt erfüllt).
**Umsetzung:** `DashboardViewModel.TodaysLessons` löst je Stunde den Raum über den zur
Stundennummer und dem heutigen Wochentag passenden `TimetableSlot` auf, die Uhrzeit aus
@@ -2185,7 +2225,16 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
- [ ] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
- [ ] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger
Emoji-Darstellung einführen.
Emoji-Darstellung einführen. **Konkreter Bericht (Nutzer-Feedback):** Drawer-Icons erscheinen
auf einem Windows-PC gegen 22 Uhr einfarbig schwarz statt farbig — passend zum bekannten
Windows-Verhalten, dass Emoji-Codepunkte je nach Font-Fallback statt der farbigen
"Segoe UI Emoji" auf die einfarbige "Segoe UI Symbol" ausweichen können (typischer Auslöser:
ein zeitgesteuerter Hell/Dunkel-Wechsel des Systemthemas). Als Sofortmaßnahme pinnt
`TextBlock.navicon` in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml) jetzt
explizit `FontFamily="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"` — auf anderen Plattformen
folgenlos, da unbekannte Fontnamen einfach übersprungen werden. Nur eine Abmilderung für die
Drawer-Navigation, kein Nachweis der Ursache und keine Lösung für die übrigen Emoji im Rest
der App — die eigentliche, dauerhafte Lösung bleibt dieser Punkt (echtes Icon-Set).
- [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird
über `MainWindowViewModel.ActiveNavItem` gesteuert.
- [x] **14.12** Tab "Übersicht" im Kurs (`GroupDetailView`, bislang nur Platzhaltertext) gefüllt —
@@ -2200,13 +2249,18 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
Karte wie im globalen Dashboard (9), hier nur auf den einen Kurs eingeschränkt statt über
alle Gruppen aggregiert. **Nachtrag (Nutzer-Feedback):** zwei weitere Karten — offene
Dokumentationen (`Documentation.IsDraft`-Zählung, dieselbe wie das Badge in
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Letztere bewusst KEIN 1:1-Abbild
der Dashboard-Fehlzeiten-Warnung: dieselbe Quote (`AttendanceBalanceService.WarningThresholdPercent`,
20 %), aber erst ab `GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten
Anwesenheits-Terminen im laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal
fehlt, hat rechnerisch schon 100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine
zu kleine Stichprobe. Dasselbe Problem hat die Fehlzeiten-Warnung im Hauptdashboard (5.2.3)
aktuell noch — dort bewusst unangetastet gelassen, wird separat nachgezogen.
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Dieselbe Quote wie die
Dashboard-Fehlzeiten-Warnung (`AttendanceBalanceService.WarningThresholdPercent`, 20 %),
aber optisch zurückhaltender (kein rotes Warnfall-Badge) und erst ab
`GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten Anwesenheits-Terminen im
laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rechnerisch schon
100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine zu kleine Stichprobe.
Dieselbe Mindeststichprobe wurde im Zuge dessen auch in die Dashboard-Fehlzeiten-Warnung
selbst nachgezogen, siehe 5.2.3. **Zweiter Nachtrag (Nutzer-Feedback):** dritte Karte
"Fehlende Hausaufgaben" — Schüler mit `ParticipationEntry.HomeworkMissing` aus der letzten
Mitarbeitssitzung (bewusst nur die letzte, nicht die gesamte Historie, analog zur
Hausaufgaben-Kontrolle oben), mit dem konkreten Status je Schüler (`HomeworkDisplay.Label`,
z.B. "Nicht gemacht Nachreichen offen").
---