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; } public string? Notes { get; set; }
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4). // 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; 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 CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { 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 TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
public enum WorkTaskStatus { Open, InProgress, Done } public enum WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly } public enum TaskRecurrence { None, Weekly, Monthly }
public enum TaskKind { WorkItem, Reminder }
@@ -6,8 +6,9 @@ using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer /// 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 /// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...). /// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
/// (offene Aufgaben, ...).
public sealed class DashboardViewModelTests public sealed class DashboardViewModelTests
{ {
private static PeriodScheduleService NewPeriodSchedule() private static PeriodScheduleService NewPeriodSchedule()
@@ -41,7 +42,7 @@ public sealed class DashboardViewModelTests
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null) FakeSessions? sessions = null, FakeEntries? entries = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -49,7 +50,7 @@ public sealed class DashboardViewModelTests
new FakeGroups([group]), new FakeSubjects([]), lessons, new FakeGroups([group]), new FakeSubjects([]), lessons,
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), 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(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
@@ -243,6 +244,45 @@ public sealed class DashboardViewModelTests
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz"); 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] [Fact]
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert() public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
{ {
@@ -373,4 +413,53 @@ public sealed class DashboardViewModelTests
Assert.Empty(vm.UnplannedLessons); 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); 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.False(vm.CanEdit);
Assert.Empty(sessions.GetByGroup(groupId)); 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 tasks = new FakeWorkTasks();
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries()); 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); await vm.AddTaskCommand.ExecuteAsync(null);
@@ -179,6 +179,26 @@ public sealed class WorkTaskListViewModelTests
Assert.Equal("Neu", vm.Tasks[0].Title); 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] [Fact]
public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde() public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde()
{ {
@@ -285,6 +305,38 @@ public sealed class AddEditWorkTaskDialogViewModelTests
Assert.NotNull(vm.Result); Assert.NotNull(vm.Result);
Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence); 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 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, /// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
/// um sich abends noch vorzubereiten. /// um sich abends noch vorzubereiten.
private const int UnplannedLessonsLookaheadDays = 1; 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 _greeting = "";
[ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentDate = "";
@@ -156,7 +161,8 @@ public partial class DashboardViewModel : ObservableObject
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5)) .OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5))
OpenTasks.Add(new() { Title = t.Title, OpenTasks.Add(new() { Title = t.Title,
DueDate = t.DueDate?.ToString("dd.MM.") ?? "", 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(); CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name)) 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)); .Select(t => (t.Item1!.Value, t.Attendance));
var balance = _attendanceBalance.Calculate(entries, from, to); 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)); items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
} }
foreach (var item in items.OrderByDescending(i => i.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)); 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) 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); var agg = Agg(session.Date);
agg.HasSession = true; 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++) for (var i = 0; i < 42; i++)
{ {
var date = gridStart.AddDays(i); var date = gridStart.AddDays(i);
@@ -659,7 +698,7 @@ public class LessonItem
public string Room { get; set; } = ""; public string Room { get; set; } = "";
public bool HasRoom => !string.IsNullOrWhiteSpace(Room); 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; } = ""; } public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ──────────────────────── // ── 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 /// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine
/// hohe Fehlquote bewusst NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rein /// 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 /// 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 /// Stichprobe. Dieselbe Konstante wie DashboardViewModel.AttendanceMinSampleSize
/// (AttendanceBalanceService/AttendanceWarningItem) — bewusst unangetastet, wird dort separat /// (Hauptdashboard-Fehlzeiten-Warnung, 5.2.3) — dort dasselbe Problem, mittlerweile ebenfalls
/// nachgezogen. /// gefixt.
private const int AttendanceMinSampleSize = 8; private const int AttendanceMinSampleSize = 8;
private readonly ILessonRepository _lessons; private readonly ILessonRepository _lessons;
@@ -55,6 +55,10 @@ public partial class GroupOverviewViewModel : ObservableObject
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = []; public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public bool HasOpenExcuses => OpenExcuses.Count > 0; 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") — /// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
/// siehe <see cref="AttendanceMinSampleSize"/>. /// siehe <see cref="AttendanceMinSampleSize"/>.
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = []; public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
@@ -97,6 +101,7 @@ public partial class GroupOverviewViewModel : ObservableObject
LoadOpenExcuses(today); LoadOpenExcuses(today);
LoadDraftDocumentationCount(); LoadDraftDocumentationCount();
LoadAttendanceNotices(today); LoadAttendanceNotices(today);
LoadMissingHomework();
} }
private void LoadNextLesson(DateOnly today) 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)); .Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
} }
/// Bewusst kein 1:1-Abbild der Dashboard-Fehlzeiten-Warnung: dieselbe Quote /// Dieselbe Mindeststichprobe wie DashboardViewModel.LoadAttendanceWarnings
/// (AttendanceBalanceService.WarningThresholdPercent), aber erst ab /// (AttendanceMinSampleSize) — erst ab genug erfassten Terminen wird eine hohe Quote gemeldet,
/// <see cref="AttendanceMinSampleSize"/> erfassten Terminen, damit ein Fehltag in der ersten /// damit ein Fehltag in der ersten Schulwoche nicht sofort als auffällig gilt.
/// Schulwoche nicht sofort als auffällig gilt.
private void LoadAttendanceNotices(DateOnly today) private void LoadAttendanceNotices(DateOnly today)
{ {
AttendanceNotices.Clear(); AttendanceNotices.Clear();
@@ -225,15 +229,41 @@ public partial class GroupOverviewViewModel : ObservableObject
var items = new List<AttendanceWarningItem>(); var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetByGroup(_groupId)) foreach (var student in _students.GetByGroup(_groupId))
{ {
if (!entriesByStudent.TryGetValue(student.Id, out var entries) || entries.Count < AttendanceMinSampleSize) if (!entriesByStudent.TryGetValue(student.Id, out var entries)) continue;
continue;
var balance = _attendanceBalance.Calculate(entries, from, to); 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)); items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
} }
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
AttendanceNotices.Add(item); AttendanceNotices.Add(item);
OnPropertyChanged(nameof(HasAttendanceNotices)); 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(); 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() private void LoadTodaySessions()
{ {
TodaySessions.Clear(); TodaySessions.Clear();
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today) var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today)
.OrderBy(s => s.CreatedAt).ToList(); .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)); foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session));
SelectedSession = TodaySessions.LastOrDefault(); 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(); partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData();
private void LoadStudentOptions() private void LoadStudentOptions()
@@ -193,19 +210,21 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private void ToggleSituationTag(SeatCellViewModel seat, string tag) 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; seat.SelectedOption.StudentId is not Guid studentId) return;
var session = EnsureTodaySession();
if (session is null) return;
var draft = _documentation.GetByStudent(studentId) 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) if (draft is null)
{ {
draft = new Documentation draft = new Documentation
{ {
StudentId = studentId, GroupId = _groupId, StudentId = studentId, GroupId = _groupId,
ParticipationSessionId = SelectedSession.Id, ParticipationSessionId = session.Id,
LessonId = SelectedSession.LessonId, LessonId = session.LessonId,
Type = DocumentationType.Incident, Type = DocumentationType.Incident,
Date = SelectedSession.Date, Date = session.Date,
Title = tag, Title = tag,
IsDraft = true, IsDraft = true,
Tags = [tag], Tags = [tag],
@@ -297,9 +316,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public async Task AssessStudent(SeatCellViewModel seat) public async Task AssessStudent(SeatCellViewModel seat)
{ {
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return; if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
var session = EnsureTodaySession();
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects, var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable, _groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
SelectedSession?.Id); session?.Id);
await OnAssessStudent(assessment); await OnAssessStudent(assessment);
RefreshSeatLessonData(); RefreshSeatLessonData();
OnAssessmentChanged?.Invoke(); OnAssessmentChanged?.Invoke();
@@ -97,7 +97,8 @@ public partial class WorkTaskListViewModel : ObservableObject
public ObservableCollection<WorkTaskListItem> Tasks { get; } = []; public ObservableCollection<WorkTaskListItem> Tasks { get; } = [];
public string CountSummary => $"{Tasks.Count} Aufgabe(n)"; 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 = []; private Dictionary<Guid, string> _groupNames = [];
@@ -166,7 +167,20 @@ public partial class WorkTaskListViewModel : ObservableObject
private async Task AddTask() private async Task AddTask()
{ {
if (OnEditTask is null) return; 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; if (result is null) return;
_tasks.Save(result); _tasks.Save(result);
Refresh(); Refresh();
@@ -176,7 +190,7 @@ public partial class WorkTaskListViewModel : ObservableObject
private async Task EditTask(WorkTaskListItem? item) private async Task EditTask(WorkTaskListItem? item)
{ {
if (item is null || OnEditTask is null) return; 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; if (result is null) return;
_tasks.Save(result); _tasks.Save(result);
Refresh(); Refresh();
@@ -203,6 +217,7 @@ public partial class WorkTaskListViewModel : ObservableObject
? dueDate.AddDays(7) : dueDate.AddMonths(1), ? dueDate.AddDays(7) : dueDate.AddMonths(1),
EstimatedMinutes = item.Model.EstimatedMinutes, EstimatedMinutes = item.Model.EstimatedMinutes,
Recurrence = item.Model.Recurrence, Recurrence = item.Model.Recurrence,
Kind = item.Model.Kind,
Notes = item.Model.Notes, Notes = item.Model.Notes,
Status = WorkTaskStatus.Open, Status = WorkTaskStatus.Open,
}); });
@@ -224,7 +239,8 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut
{ {
public WorkTask Model { get; } = model; public WorkTask Model { get; } = model;
public string Title => Model.Title; 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 GroupName => groupName ?? "";
public string DueDateDisplay => Model.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? ""; public string DueDateDisplay => Model.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
public bool IsOverdue => Model.DueDate is { } d && d < DateOnly.FromDateTime(DateTime.Today) 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 _estimatedMinutesText = "";
[ObservableProperty] private string _selectedRecurrence = TaskRecurrenceDisplay.Options[0]; [ObservableProperty] private string _selectedRecurrence = TaskRecurrenceDisplay.Options[0];
[ObservableProperty] private string _notes = ""; [ObservableProperty] private string _notes = "";
[ObservableProperty] private bool _isReminder;
[ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dueDateError = ""; [ObservableProperty] private string _dueDateError = "";
[ObservableProperty] private string _estimatedMinutesError = ""; [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> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options]; public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options];
public List<LearningGroup> Groups { get; } public List<LearningGroup> Groups { get; }
public WorkTask? Result { get; private set; } 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; _source = source;
Groups = groups; Groups = groups;
if (source is null) return; if (source is null) { IsReminder = startAsReminder; return; }
Title = source.Title; Title = source.Title;
SelectedCategory = TaskCategoryDisplay.Label(source.Category); SelectedCategory = TaskCategoryDisplay.Label(source.Category);
@@ -281,6 +302,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? ""; EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence); SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence);
Notes = source.Notes ?? ""; Notes = source.Notes ?? "";
IsReminder = source.Kind == TaskKind.Reminder;
} }
[RelayCommand] [RelayCommand]
@@ -303,8 +325,9 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
if (recurrence != TaskRecurrence.None && dueDate is null) if (recurrence != TaskRecurrence.None && dueDate is null)
{ DueDateError = "Fälligkeitsdatum erforderlich, damit die nächste Instanz geplant werden kann."; valid = false; } { 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; int? estimatedMinutes = null;
if (!string.IsNullOrWhiteSpace(EstimatedMinutesText)) if (!IsReminder && !string.IsNullOrWhiteSpace(EstimatedMinutesText))
{ {
if (!int.TryParse(EstimatedMinutesText, out var m) || m <= 0) if (!int.TryParse(EstimatedMinutesText, out var m) || m <= 0)
{ EstimatedMinutesError = "Ganze Zahl > 0 erwartet."; valid = false; } { EstimatedMinutesError = "Ganze Zahl > 0 erwartet."; valid = false; }
@@ -322,6 +345,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
DueDate = dueDate, DueDate = dueDate,
EstimatedMinutes = estimatedMinutes, EstimatedMinutes = estimatedMinutes,
Recurrence = recurrence, Recurrence = recurrence,
Kind = IsReminder ? TaskKind.Reminder : TaskKind.WorkItem,
Status = _source?.Status ?? WorkTaskStatus.Open, Status = _source?.Status ?? WorkTaskStatus.Open,
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(), Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow, CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow,
@@ -108,10 +108,13 @@
<ItemsControl ItemsSource="{Binding OpenTasks}"> <ItemsControl ItemsSource="{Binding OpenTasks}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskItem"> <DataTemplate DataType="vm:TaskItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,3"> <Grid ColumnDefinitions="Auto,*,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Title}" <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"/> 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"/> FontSize="12" Opacity="0.6" Margin="8,0,0,0"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
@@ -143,6 +143,28 @@
</ItemsControl> </ItemsControl>
</StackPanel> </StackPanel>
</Border> </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> </WrapPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
+5 -1
View File
@@ -67,10 +67,14 @@
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}"> <DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
<DockPanel.Styles> <DockPanel.Styles>
<!-- Basisgröße der Icons als Style (nicht lokal), damit der Compact-Selector sie <!-- 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"> <Style Selector="TextBlock.navicon">
<Setter Property="FontSize" Value="15"/> <Setter Property="FontSize" Value="15"/>
<Setter Property="Width" Value="24"/> <Setter Property="Width" Value="24"/>
<Setter Property="FontFamily" Value="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"/>
</Style> </Style>
<Style Selector="Button.navitem"> <Style Selector="Button.navitem">
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
@@ -20,6 +20,9 @@
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<CheckBox Content="🔔 Nur Erinnerung (kein Zeitbezug, läuft nicht in die Zeiterfassung ein)"
IsChecked="{Binding IsReminder}"/>
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Kategorie *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Kategorie *" FontSize="12" Opacity="0.7"/>
@@ -46,7 +49,7 @@
<TextBlock Text="{Binding DueDateError}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding DueDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DueDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding DueDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </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"/> <TextBlock Text="Geschätzte Dauer in min (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding EstimatedMinutesText}" PlaceholderText="z.B. 90"/> <TextBox Text="{Binding EstimatedMinutesText}" PlaceholderText="z.B. 90"/>
<TextBlock Text="{Binding EstimatedMinutesError}" Foreground="Red" FontSize="11" <TextBlock Text="{Binding EstimatedMinutesError}" Foreground="Red" FontSize="11"
@@ -11,7 +11,11 @@
SelectedItem="{Binding CategoryFilter}" Margin="0,0,8,0"/> SelectedItem="{Binding CategoryFilter}" Margin="0,0,8,0"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding GroupFilterOptions}" <ComboBox Grid.Column="2" ItemsSource="{Binding GroupFilterOptions}"
SelectedItem="{Binding GroupFilter}"/> 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> </Grid>
<ScrollViewer Grid.Row="1" Margin="16,10,16,16"> <ScrollViewer Grid.Row="1" Margin="16,10,16,16">
@@ -30,7 +34,11 @@
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/> <TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/>
</Button> </Button>
<StackPanel Grid.Column="1" Margin="10,0" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Margin="10,0" VerticalAlignment="Center">
<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"/> <TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/>
</StackPanel>
<TextBlock FontSize="11" Opacity="0.6"> <TextBlock FontSize="11" Opacity="0.6">
<Run Text="{Binding CategoryLabel}"/> <Run Text="{Binding CategoryLabel}"/>
<Run Text=" · "/> <Run Text=" · "/>
@@ -17,13 +17,13 @@ public partial class WorkTaskListView : UserControl
vm.OnEditTask = ShowEditDialog; 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; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null; if (owner is null) return null;
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll(includeInactive: true); 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 }; var dialog = new AddEditWorkTaskDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner); await dialog.ShowDialog<bool>(owner);
return vm.Result; 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. (`OtherSchoolEvent`) zählt bewusst nicht als Fehlzeit des Schülers.
- [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im - [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im
Schülerdetail sowie eine neue "Fehlzeiten-Warnung"-Karte im Dashboard (alle Schüler über 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 - [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). "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 auf `Conducted` (Anbindung an 1.1.3) — Hook in `GroupDetailViewModel.SetExamStatus`, feuert
nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten
Klick), Fälligkeit 14 Tage nach dem Klausurtermin, Kategorie `Correction`. 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 ### 6.2 Zeiterfassung
- [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie — - [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 Mitarbeitsaspekte, Anwesenheit und Hausaufgaben. Die Eingabe unterstützt Maus sowie
Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert, Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert,
wird einmalig eine Sitzung „Sitzplan“ für heute angelegt. 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 ### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/ - [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, Kleiner Monatskalender ist bereits umgesetzt (Farbcodierung Unterricht/Klausur,
Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreite/-position). 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). - [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 **Umsetzung:** `DashboardViewModel.TodaysLessons` löst je Stunde den Raum über den zur
Stundennummer und dem heutigen Wochentag passenden `TimetableSlot` auf, die Uhrzeit aus 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, - [ ] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln. Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
- [ ] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger - [ ] **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 - [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird
über `MainWindowViewModel.ActiveNavItem` gesteuert. über `MainWindowViewModel.ActiveNavItem` gesteuert.
- [x] **14.12** Tab "Übersicht" im Kurs (`GroupDetailView`, bislang nur Platzhaltertext) gefüllt — - [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 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 alle Gruppen aggregiert. **Nachtrag (Nutzer-Feedback):** zwei weitere Karten — offene
Dokumentationen (`Documentation.IsDraft`-Zählung, dieselbe wie das Badge in Dokumentationen (`Documentation.IsDraft`-Zählung, dieselbe wie das Badge in
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Letztere bewusst KEIN 1:1-Abbild `GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Dieselbe Quote wie die
der Dashboard-Fehlzeiten-Warnung: dieselbe Quote (`AttendanceBalanceService.WarningThresholdPercent`, Dashboard-Fehlzeiten-Warnung (`AttendanceBalanceService.WarningThresholdPercent`, 20 %),
20 %), aber erst ab `GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten aber optisch zurückhaltender (kein rotes Warnfall-Badge) und erst ab
Anwesenheits-Terminen im laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal `GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten Anwesenheits-Terminen im
fehlt, hat rechnerisch schon 100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rechnerisch schon
zu kleine Stichprobe. Dasselbe Problem hat die Fehlzeiten-Warnung im Hauptdashboard (5.2.3) 100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine zu kleine Stichprobe.
aktuell noch — dort bewusst unangetastet gelassen, wird separat nachgezogen. 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").
--- ---