feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard
- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
und der letzten Stunde der Einheit vorbelegt statt auf "heute"
(behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
für die zweite Periode) und Berücksichtigung von Stundenausfall.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,13 @@ public class LearningGroup
|
||||
public bool IsActive { get; set; } = true;
|
||||
public bool IsOwnClass { get; set; }
|
||||
public bool IsDifferentiated { get; set; }
|
||||
/// <summary>
|
||||
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
|
||||
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
|
||||
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
|
||||
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
||||
/// </summary>
|
||||
public bool RequiresLessonPlanning { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,6 @@ public class TimeEntry
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
|
||||
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
|
||||
public enum WorkTaskStatus { Open, InProgress, Done }
|
||||
public enum TaskRecurrence { None, Weekly, Monthly }
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class DashboardSettingsService
|
||||
public static readonly string[] DefaultCardOrder =
|
||||
[
|
||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||
"corrections", "alerts", "attendance", "support", "groups",
|
||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
|
||||
];
|
||||
|
||||
private readonly string _configPath;
|
||||
|
||||
@@ -26,14 +26,23 @@ public sealed class DashboardViewModelTests
|
||||
return new DashboardSettingsService(tempPath);
|
||||
}
|
||||
|
||||
private static SchoolCalendarSettingsService NewCalendarSettings()
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new SchoolCalendarSettingsService(tempPath);
|
||||
}
|
||||
|
||||
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
|
||||
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
|
||||
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
|
||||
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
||||
DashboardSettingsService? dashboardSettings = null)
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null)
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
return new DashboardViewModel(
|
||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||
@@ -42,7 +51,9 @@ public sealed class DashboardViewModelTests
|
||||
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
|
||||
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings());
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||
substitutions ?? new FakeSubstitutionEntries());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -227,4 +238,119 @@ public sealed class DashboardViewModelTests
|
||||
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
|
||||
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
|
||||
}
|
||||
|
||||
// ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
// "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
|
||||
// Vorgriff fällt und den Test verfälscht.
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||
|
||||
var item = Assert.Single(vm.UnplannedLessons);
|
||||
Assert.Equal(group.Id, item.GroupId);
|
||||
Assert.Equal(1, item.PeriodNumber);
|
||||
Assert.Equal("Heute", item.DateDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redoxreaktionen" };
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_Feiertag_WirdUebersprungen()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||
var schoolHolidays = new FakeSchoolHolidays();
|
||||
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Equal(2, vm.UnplannedLessons.Count);
|
||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
|
||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
|
||||
var substitutions = new FakeSubstitutionEntries();
|
||||
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
|
||||
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Sync;
|
||||
|
||||
@@ -47,6 +48,14 @@ public static class TestSupport
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new EventQueue(Path.Combine(tempPath, "queue.db"));
|
||||
}
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static PeriodScheduleService BuildPeriodScheduleService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new PeriodScheduleService(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||
|
||||
@@ -388,4 +388,53 @@ public sealed class LessonDialogViewModelTests
|
||||
|
||||
Assert.False(vm.HasTimeBudgetInfo);
|
||||
}
|
||||
|
||||
// ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf
|
||||
// "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode
|
||||
// zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum()
|
||||
{
|
||||
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer
|
||||
|
||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 });
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
|
||||
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
// Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
|
||||
Assert.Equal("13.01.2099", vm.DateText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 });
|
||||
var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
|
||||
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
|
||||
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
|
||||
Assert.Equal(weekday, suggested.DayOfWeek);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Workload;
|
||||
@@ -288,14 +289,16 @@ public sealed class AddEditWorkTaskDialogViewModelTests
|
||||
|
||||
public sealed class TimeTrackingViewModelTests
|
||||
{
|
||||
private static TimeTrackingViewModel BuildVm(ITimeEntryRepository entries, IWorkTaskRepository tasks,
|
||||
FakeTimetableSlots? slots = null) =>
|
||||
new(entries, tasks, slots ?? new FakeTimetableSlots(), TestSupport.BuildPeriodScheduleService());
|
||||
|
||||
[Fact]
|
||||
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
|
||||
{
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
|
||||
{
|
||||
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
|
||||
};
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
vm.SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction);
|
||||
|
||||
vm.StartTimerCommand.Execute(null);
|
||||
Assert.True(vm.IsTimerRunning);
|
||||
@@ -314,7 +317,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(task);
|
||||
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
|
||||
var vm = BuildVm(new FakeTimeEntries(), tasks);
|
||||
|
||||
vm.SelectedTimerTask = task;
|
||||
|
||||
@@ -328,7 +331,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
|
||||
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
|
||||
Assert.Single(vm.WeekEntries);
|
||||
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
|
||||
@@ -343,7 +346,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
|
||||
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
|
||||
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
|
||||
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
|
||||
Assert.Equal("50 min", korrektur.MinutesDisplay);
|
||||
@@ -356,7 +359,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
{
|
||||
var entries = new FakeTimeEntries();
|
||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
|
||||
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
|
||||
|
||||
@@ -367,7 +370,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
|
||||
{
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
|
||||
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
|
||||
|
||||
@@ -375,6 +378,64 @@ public sealed class TimeTrackingViewModelTests
|
||||
|
||||
Assert.Single(vm.WeekEntries);
|
||||
}
|
||||
|
||||
// ── Unterrichtszeit-Vorschlag (Nutzer-Feedback: Zeiterfassung soll bei der Unterrichtszeit
|
||||
// nachfragen statt sie komplett automatisch zu erfassen) ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HasTeachingTimeSuggestionToday_OhneStundenplanHeute_IstFalse()
|
||||
{
|
||||
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks()); // leere FakeTimetableSlots
|
||||
|
||||
Assert.False(vm.HasTeachingTimeSuggestionToday);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestTeachingTime_SchlaegtBeginnMitPufferVorErsterUndNachLetzterPeriodeVor()
|
||||
{
|
||||
var today = DateTime.Today.DayOfWeek;
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 1 });
|
||||
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 2 });
|
||||
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||
periodSchedule.SetPeriods([
|
||||
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
|
||||
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
|
||||
]);
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
|
||||
|
||||
Assert.True(vm.HasTeachingTimeSuggestionToday);
|
||||
|
||||
(TimeOnly Start, TimeOnly End)? suggested = null;
|
||||
vm.OnSuggestTeachingTime = (start, end) =>
|
||||
{
|
||||
suggested = (start, end);
|
||||
return Task.FromResult<TimeEntry?>(new TimeEntry
|
||||
{
|
||||
Category = "Unterricht", Date = DateOnly.FromDateTime(DateTime.Today),
|
||||
StartTime = start, EndTime = end, DurationMinutes = (int)(end - start).TotalMinutes,
|
||||
});
|
||||
};
|
||||
|
||||
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(new TimeOnly(7, 35), suggested!.Value.Start); // 7:50 - 15 Min Puffer
|
||||
Assert.Equal(new TimeOnly(9, 30), suggested.Value.End); // 9:20 + 10 Min Puffer
|
||||
Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestTeachingTime_OhneStundenplanHeute_TutNichts()
|
||||
{
|
||||
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks());
|
||||
var called = false;
|
||||
vm.OnSuggestTeachingTime = (_, _) => { called = true; return Task.FromResult<TimeEntry?>(null); };
|
||||
|
||||
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(called);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AddTimeEntryDialogViewModelTests
|
||||
|
||||
@@ -101,6 +101,7 @@ public class App : Application
|
||||
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
|
||||
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||
|
||||
// StudentList → StudentDetail + Anlegen
|
||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||
|
||||
@@ -30,10 +30,18 @@ public partial class DashboardViewModel : ObservableObject
|
||||
private readonly AttendanceBalanceService _attendanceBalance;
|
||||
private readonly SchoolYearService _sy;
|
||||
private readonly DashboardSettingsService _dashboardSettings;
|
||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||
private readonly PublicHolidayService _publicHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
private readonly ISubstitutionEntryRepository _substitutions;
|
||||
|
||||
private const int OpenExcuseMaxAgeDays = 21;
|
||||
private const int SupportPlanDueWithinDays = 14;
|
||||
private const int UpcomingWithinDays = 30;
|
||||
/// Nutzer-Feedback: "der zeitliche Vorgriff sollte sinnvoll sein" - heute + morgen ist knapp
|
||||
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
|
||||
/// um sich abends noch vorzubereiten.
|
||||
private const int UnplannedLessonsLookaheadDays = 1;
|
||||
|
||||
[ObservableProperty] private string _greeting = "";
|
||||
[ObservableProperty] private string _currentDate = "";
|
||||
@@ -53,6 +61,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
||||
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
||||
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
||||
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
|
||||
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
|
||||
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
|
||||
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
|
||||
@@ -66,6 +75,9 @@ public partial class DashboardViewModel : ObservableObject
|
||||
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
|
||||
public Action<Guid>? OnNavigateToLesson { get; set; }
|
||||
public Action<Guid>? OnNavigateToExam { get; set; }
|
||||
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||||
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||||
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
||||
|
||||
public DashboardCardOption TodayCard => Card("today");
|
||||
public DashboardCardOption TasksCard => Card("tasks");
|
||||
@@ -73,6 +85,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
public DashboardCardOption ExcusesCard => Card("excuses");
|
||||
public DashboardCardOption UpcomingCard => Card("upcoming");
|
||||
public DashboardCardOption CorrectionsCard => Card("corrections");
|
||||
public DashboardCardOption UnplannedCard => Card("unplanned");
|
||||
public DashboardCardOption AlertsCard => Card("alerts");
|
||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
||||
public DashboardCardOption SupportCard => Card("support");
|
||||
@@ -85,7 +98,9 @@ public partial class DashboardViewModel : ObservableObject
|
||||
IParticipationRepository participationEntries, IStudentRepository students,
|
||||
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
|
||||
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
||||
DashboardSettingsService dashboardSettings)
|
||||
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||
ISubstitutionEntryRepository substitutions)
|
||||
{
|
||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
|
||||
@@ -93,6 +108,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
_students = students; _documentation = documentation;
|
||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
||||
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||
_substitutions = substitutions;
|
||||
LoadDashboardCards();
|
||||
Load();
|
||||
}
|
||||
@@ -157,6 +174,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
LoadSupportPlanReviews(today);
|
||||
LoadUpcomingDates(groups, today);
|
||||
LoadOpenCorrections(groups, today);
|
||||
LoadUnplannedLessons(groups, today);
|
||||
LoadAlerts(groups, today);
|
||||
}
|
||||
|
||||
@@ -264,6 +282,87 @@ public partial class DashboardViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ungeplante Stunden (Nutzer-Feedback: Erinnerung an Stunden ohne Thema) ────────────────
|
||||
//
|
||||
// Nur heute + UnplannedLessonsLookaheadDays (bewusst knapp, siehe Konstante oben) — für jede
|
||||
// Gruppe mit RequiresLessonPlanning und einem Stundenplan-Slot an diesem Wochentag wird
|
||||
// geprüft, ob dafür bereits eine Lesson mit Thema existiert. Gruppen ohne eigenen Verlaufsplan
|
||||
// (Klassenrat, Willkommenskreis) lassen sich in den Stammdaten der Gruppe ausnehmen. Ein Slot,
|
||||
// für den an diesem Datum ein SubstitutionKind.Cancelled-Eintrag (4.3, "Stundenausfall")
|
||||
// vorliegt, fällt ganz weg — gleiche Prüfung (Datum + Stundennummer, ohne Gruppenbezug) wie in
|
||||
// TimetableViewModel.BuildToday.
|
||||
|
||||
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||
{
|
||||
UnplannedLessons.Clear();
|
||||
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
|
||||
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
|
||||
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||
.Select(h => h.Date).ToHashSet();
|
||||
var schoolHolidays = _schoolHolidays.GetAll();
|
||||
|
||||
var items = new List<UnplannedLessonItem>();
|
||||
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
|
||||
{
|
||||
var slots = _timetableSlots.GetByGroup(group.Id);
|
||||
if (slots.Count == 0) continue;
|
||||
|
||||
for (var date = today; date <= lastDay; date = date.AddDays(1))
|
||||
{
|
||||
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||||
var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||
if (daySlots.Count == 0) continue;
|
||||
var periodsWithSlot = daySlots.Select(s => s.PeriodNumber).ToHashSet();
|
||||
var dayLessons = _lessons.GetByGroupAndDate(group.Id, date).ToList();
|
||||
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||
.Select(s => s.PeriodNumber).ToHashSet();
|
||||
|
||||
foreach (var slot in daySlots)
|
||||
{
|
||||
if (cancelledPeriods.Contains(slot.PeriodNumber)) continue;
|
||||
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == slot.PeriodNumber);
|
||||
if (lesson is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(lesson.Topic)) continue;
|
||||
}
|
||||
else if (IsCoveredByEarlierDoppelstunde(dayLessons, periodsWithSlot, slot.PeriodNumber))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today));
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName))
|
||||
UnplannedLessons.Add(item);
|
||||
}
|
||||
|
||||
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
|
||||
/// bei einer früheren Stundennummer beginnt — gleiches Prinzip wie
|
||||
/// PlanningViewModels.LessonDialogViewModel.RecomputeTimeBudget: ausgehend von der Vorperiode
|
||||
/// wird rückwärts so lange die jeweils vorherige Periode geprüft, wie der Stundenplan dafür
|
||||
/// noch einen Slot hat. Trifft man dabei auf eine Lesson, entscheidet deren Thema (vorhanden =
|
||||
/// Doppelstunde bereits geplant); trifft man auf eine Periode ohne Lesson, wird weiter
|
||||
/// zurückgegangen; bricht die Slot-Kette ab, ohne eine Lesson gefunden zu haben, ist die Periode
|
||||
/// nicht abgedeckt.</summary>
|
||||
private static bool IsCoveredByEarlierDoppelstunde(
|
||||
List<Lesson> dayLessons, HashSet<int> periodsWithSlot, int periodNumber)
|
||||
{
|
||||
for (var period = periodNumber - 1; periodsWithSlot.Contains(period); period--)
|
||||
{
|
||||
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == period);
|
||||
if (lesson is not null) return !string.IsNullOrWhiteSpace(lesson.Topic);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in
|
||||
/// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein
|
||||
/// Service-Aufwand für eine dritte Fundstelle.</summary>
|
||||
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
|
||||
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
|
||||
|
||||
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
|
||||
|
||||
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||
@@ -330,7 +429,7 @@ public partial class DashboardViewModel : ObservableObject
|
||||
{
|
||||
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
|
||||
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
||||
"corrections" => "Offene Korrekturen", "alerts" => "Auffälligkeiten",
|
||||
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
|
||||
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
||||
"groups" => "Meine Lerngruppen", _ => key,
|
||||
};
|
||||
@@ -524,6 +623,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
}
|
||||
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
|
||||
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
|
||||
[RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
|
||||
{ if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
|
||||
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
|
||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
||||
[RelayCommand] private void Refresh() => Load();
|
||||
@@ -685,6 +786,16 @@ public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string tit
|
||||
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
|
||||
}
|
||||
|
||||
public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
|
||||
{
|
||||
public Guid GroupId { get; } = groupId;
|
||||
public string GroupName { get; } = groupName;
|
||||
public DateOnly Date { get; } = date;
|
||||
public int PeriodNumber { get; } = periodNumber;
|
||||
public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
|
||||
public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
|
||||
}
|
||||
|
||||
public enum AlertSeverity { Medium, High }
|
||||
|
||||
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
|
||||
|
||||
@@ -810,6 +810,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private int? _hoursPerWeek;
|
||||
[ObservableProperty] private bool _isOwnClass;
|
||||
[ObservableProperty] private bool _isDifferentiated;
|
||||
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||
[ObservableProperty] private string _nameError = "";
|
||||
[ObservableProperty] private string _gradeLevelError = "";
|
||||
|
||||
@@ -848,6 +849,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
||||
HoursPerWeek = group.HoursPerWeek;
|
||||
IsOwnClass = group.IsOwnClass;
|
||||
IsDifferentiated = group.IsDifferentiated;
|
||||
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||
OnPropertyChanged(nameof(DialogTitle));
|
||||
OnPropertyChanged(nameof(SaveButtonText));
|
||||
}
|
||||
@@ -893,6 +895,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
||||
Result.HoursPerWeek = HoursPerWeek;
|
||||
Result.IsOwnClass = IsOwnClass;
|
||||
Result.IsDifferentiated = IsDifferentiated;
|
||||
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||
_groups.Save(Result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,9 +711,35 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
|
||||
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
|
||||
}
|
||||
else
|
||||
{
|
||||
DateText = SuggestNextLessonDate().ToString("dd.MM.yyyy");
|
||||
}
|
||||
RecomputeTimes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vorbelegung für eine neue Stunde: statt des sonst über den Feld-Default eingesetzten
|
||||
/// heutigen Datums (das an einem beliebigen Wochentag steht und die Doppelstunden-Erkennung
|
||||
/// in <see cref="RecomputeTimeBudget"/> stillschweigend auf eine Einzelperiode zurückfallen
|
||||
/// lässt, wenn der Wochentag nicht zufällig passt) der nächste laut Stundenplan (4.3) für
|
||||
/// diese Gruppe passende Wochentag — ab der letzten Stunde dieser Einheit, oder ab heute, wenn
|
||||
/// noch keine Stunde in dieser Einheit existiert oder die letzte in der Vergangenheit liegt.
|
||||
/// Ohne Stundenplan-Einträge für die Gruppe (z.B. Klassenrat) bleibt es beim heutigen Datum.
|
||||
/// </summary>
|
||||
private DateOnly SuggestNextLessonDate()
|
||||
{
|
||||
var weekdays = _timetableSlots.GetByGroup(_groupId).Select(s => s.Weekday).ToHashSet();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
if (weekdays.Count == 0) return today;
|
||||
|
||||
var lastLessonDate = _lessons.GetByUnit(_unitId).Select(l => l.Date)
|
||||
.DefaultIfEmpty(today.AddDays(-1)).Max();
|
||||
var candidate = lastLessonDate >= today ? lastLessonDate.AddDays(1) : today;
|
||||
while (!weekdays.Contains(candidate.DayOfWeek)) candidate = candidate.AddDays(1);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddPhase()
|
||||
{
|
||||
|
||||
@@ -46,6 +46,7 @@ public static class TaskCategoryDisplay
|
||||
TaskCategory.Admin => "Verwaltung",
|
||||
TaskCategory.Meeting => "Besprechung",
|
||||
TaskCategory.Other => "Sonstiges",
|
||||
TaskCategory.Teaching => "Unterricht",
|
||||
_ => c.ToString(),
|
||||
};
|
||||
|
||||
@@ -333,6 +334,15 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
{
|
||||
private readonly ITimeEntryRepository _entries;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly ITimetableSlotRepository _timetableSlots;
|
||||
private readonly PeriodScheduleService _periodSchedule;
|
||||
|
||||
// Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch
|
||||
// nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible
|
||||
// Puffer für den Unterrichtszeit-Vorschlag. Der Vorschlag füllt nur den Nacherfassen-Dialog
|
||||
// vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
|
||||
private const int BufferBeforeFirstPeriodMinutes = 15;
|
||||
private const int BufferAfterLastPeriodMinutes = 10;
|
||||
|
||||
public const string NoTaskOption = "Keine Aufgabe";
|
||||
|
||||
@@ -351,12 +361,24 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
|
||||
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
|
||||
|
||||
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
|
||||
/// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
|
||||
/// "Unterrichtszeit übernehmen"-Button überhaupt sichtbar ist.
|
||||
public bool HasTeachingTimeSuggestionToday => ComputeTodaysTeachingWindow() is not null;
|
||||
|
||||
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks)
|
||||
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
|
||||
/// Wie OnAddEntry, aber öffnet den Dialog mit vorbefüllter Kategorie "Unterricht" und den
|
||||
/// laut Stundenplan/Stundenraster vorgeschlagenen Beginn-/Ende-Zeiten - eine echte Bestätigung
|
||||
/// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer-
|
||||
/// Konstanten oben).
|
||||
public Func<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
|
||||
|
||||
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
|
||||
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
|
||||
{
|
||||
_entries = entries;
|
||||
_tasks = tasks;
|
||||
_timetableSlots = timetableSlots;
|
||||
_periodSchedule = periodSchedule;
|
||||
Load();
|
||||
}
|
||||
|
||||
@@ -442,6 +464,38 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
Refresh();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SuggestTeachingTime()
|
||||
{
|
||||
if (OnSuggestTeachingTime is null || ComputeTodaysTeachingWindow() is not var (start, end)) return;
|
||||
var result = await OnSuggestTeachingTime(start, end);
|
||||
if (result is null) return;
|
||||
_entries.Save(result);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht
|
||||
/// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten
|
||||
/// Puffer erweitert. Ohne Stundenplan-Eintrag heute oder ohne im Stundenraster hinterlegte
|
||||
/// Zeiten gibt es keinen Vorschlag (null) statt einer erfundenen Zeit.
|
||||
/// </summary>
|
||||
private (TimeOnly Start, TimeOnly End)? ComputeTodaysTeachingWindow()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today).DayOfWeek;
|
||||
var periodTimes = _timetableSlots.GetAll()
|
||||
.Where(s => s.Weekday == today)
|
||||
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
|
||||
.Where(t => t is not null)
|
||||
.Select(t => t!.Value)
|
||||
.ToList();
|
||||
if (periodTimes.Count == 0) return null;
|
||||
|
||||
var start = periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes);
|
||||
var end = periodTimes.Max(t => t.End).AddMinutes(BufferAfterLastPeriodMinutes);
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteEntry(TimeEntryListItem? item)
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
|
||||
<!-- Heutige Stunden -->
|
||||
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
|
||||
@@ -415,6 +415,36 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Ungeplante Stunden -->
|
||||
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
|
||||
IsVisible="{Binding UnplannedCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="UNGEPLANTE STUNDEN" FontSize="11" FontWeight="Bold"
|
||||
Opacity="0.5" Margin="0,0,0,10"/>
|
||||
<ItemsControl ItemsSource="{Binding UnplannedLessons}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:UnplannedLessonItem">
|
||||
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUnplannedLessonCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="{Binding Display}" FontSize="13"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
|
||||
VerticalAlignment="Center" Opacity="0.6"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine ungeplanten Stunden." Classes="emptyhint"
|
||||
IsVisible="{Binding !UnplannedLessons.Count}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Auffälligkeiten (9.5) -->
|
||||
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
|
||||
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
|
||||
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
|
||||
|
||||
<CheckBox Content="Benötigt Unterrichtsplanung" IsChecked="{Binding RequiresLessonPlanning}"
|
||||
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung "Ungeplante Stunden" aus."/>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
|
||||
@@ -66,9 +66,13 @@
|
||||
</Border>
|
||||
|
||||
<!-- Einträge / Nacherfassung (6.2.2) -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="+ Nacherfassen" Command="{Binding AddEntryCommand}"/>
|
||||
<Button Grid.Column="1" Content="🕓 Unterrichtszeit heute übernehmen" Margin="0,0,8,0"
|
||||
Command="{Binding SuggestTeachingTimeCommand}"
|
||||
IsVisible="{Binding HasTeachingTimeSuggestionToday}"
|
||||
ToolTip.Tip="Schlägt Beginn/Ende anhand des heutigen Stundenplans vor (mit etwas Puffer davor/danach) - im Dialog vor dem Speichern noch anpassbar."/>
|
||||
<Button Grid.Column="2" Content="+ Nacherfassen" Command="{Binding AddEntryCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding WeekEntries}">
|
||||
|
||||
@@ -14,10 +14,15 @@ public partial class TimeTrackingView : UserControl
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is TimeTrackingViewModel vm)
|
||||
vm.OnAddEntry = ShowAddEntryDialog;
|
||||
{
|
||||
vm.OnAddEntry = () => ShowAddEntryDialog();
|
||||
vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht",
|
||||
prefillStart: start, prefillEnd: end);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TimeEntry?> ShowAddEntryDialog()
|
||||
private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null,
|
||||
TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return null;
|
||||
@@ -25,6 +30,9 @@ public partial class TimeTrackingView : UserControl
|
||||
var tasks = App.Services.GetRequiredService<IWorkTaskRepository>()
|
||||
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||
var vm = new AddTimeEntryDialogViewModel(tasks);
|
||||
if (prefillCategory is not null) vm.SelectedCategory = prefillCategory;
|
||||
if (prefillStart is { } s) vm.StartTimeText = s.ToString("HH:mm");
|
||||
if (prefillEnd is { } e) vm.EndTimeText = e.ToString("HH:mm");
|
||||
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
return vm.Result;
|
||||
|
||||
@@ -460,6 +460,20 @@ Redesign:
|
||||
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
|
||||
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
|
||||
|
||||
**Nachtrag — Datum-Vorbelegung beim manuellen "+ Stunde"-Dialog:** Nutzer-Bug-Report: eine
|
||||
manuell angelegte Doppelstunde zeigte immer 45 statt 90 Minuten Zeitbedarf. Ursache war kein
|
||||
Fehler in der Doppelstunden-Erkennung (Nachtrag zu 4.2.2 oben) selbst, sondern dass `DateText`
|
||||
im `LessonDialogViewModel` beim Neuanlegen immer auf "heute" vorbelegt war — traf dieses Datum
|
||||
nicht auf den Wochentag der eingetragenen Stundennummer im Stundenplan, lief die
|
||||
weekday-basierte Nachschlage-Logik für die Folgeperiode ins Leere und die Erkennung blieb
|
||||
stumm bei 45 Minuten, ohne dass das im UI ersichtlich war. `LessonDialogViewModel.
|
||||
SuggestNextLessonDate()` ersetzt jetzt den "heute"-Default beim Neuanlegen: sie ermittelt aus
|
||||
`TimetableSlot` alle Wochentage, an denen die Gruppe laut Stundenplan Unterricht hat, nimmt das
|
||||
späteste bereits existierende `Lesson.Date` der Einheit (oder heute, falls die Einheit noch
|
||||
leer ist) und rollt von dort vorwärts auf den nächsten passenden Wochentag. Fehlt ein
|
||||
Stundenplan-Eintrag für die Gruppe, bleibt "heute" als Fallback erhalten (keine Verhaltens-
|
||||
änderung für Gruppen ohne Stundenplan).
|
||||
|
||||
### 4.3 Stundenplan
|
||||
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
|
||||
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
|
||||
@@ -1101,6 +1115,22 @@ Auswertung (6.3.3), der am noch nicht existierenden Kapitel 11 (Export-Infrastru
|
||||
Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
|
||||
`ITimeEntryRepository`.
|
||||
|
||||
**Nachtrag — Komfort-Funktion "Unterrichtszeit heute übernehmen":** Nutzer-Wunsch: Unterrichtszeit
|
||||
soll nicht komplett automatisch erfasst werden ("weiß nicht, ob das rechtlich sinnvoll ist"),
|
||||
aber ein Vorschlag mit Rückfrage ist erwünscht — man beginnt vor der ersten Stunde und geht nicht
|
||||
sofort nach der letzten. Neuer Button "🕓 Unterrichtszeit heute übernehmen" in
|
||||
`TimeTrackingView` (nur sichtbar, wenn die Gruppe heute laut Stundenplan überhaupt Unterricht
|
||||
hat — `TimeTrackingViewModel.HasTeachingTimeSuggestionToday`). `ComputeTodaysTeachingWindow()`
|
||||
ermittelt aus allen `TimetableSlot`-Einträgen des heutigen Wochentags (`ITimetableSlotRepository`)
|
||||
und dem Stundenraster (6.2/4.2, `PeriodScheduleService`) die früheste Start- und späteste
|
||||
Endzeit des Tages, zieht einen festen Puffer davor (`BufferBeforeFirstPeriodMinutes = 15`) und
|
||||
danach (`BufferAfterLastPeriodMinutes = 10`) ab/dazu. Klick öffnet den bestehenden
|
||||
`AddTimeEntryDialog` (6.2.2) mit Kategorie "Unterricht" sowie Start/Ende vorbefüllt — bewusst
|
||||
weiterhin ein normaler, vom Nutzer bestätigter Nacherfassungs-Dialog, keine automatische
|
||||
Buchung ohne Blick darauf. Neuer `TaskCategory.Teaching`-Wert (`TaskCategoryDisplay.Label`:
|
||||
"Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu
|
||||
verschieben.
|
||||
|
||||
### 6.3 Auswertung
|
||||
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
|
||||
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat
|
||||
@@ -1294,6 +1324,46 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
|
||||
Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
|
||||
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
|
||||
werden, sobald dafür ein eigenes Termine-Modell existiert.
|
||||
- [x] **9.9** Kachel "Ungeplante Stunden": erinnert an Stunden ohne Thema, für die noch kein
|
||||
Verlaufsplan existiert. Nutzer-Wunsch: der zeitliche Vorgriff soll sinnvoll begrenzt sein
|
||||
(morgens sollte wenigstens ein Stundenthema bereits bekannt sein) und einzelne Gruppen ohne
|
||||
inhaltlichen Verlaufsplan (Klassenrat, Willkommenskreis) sollen sich ausnehmen lassen.
|
||||
**Umsetzung:** `DashboardViewModel.LoadUnplannedLessons` läuft für jede Gruppe mit
|
||||
`LearningGroup.RequiresLessonPlanning == true` (neues Feld, Default `true` — bestehende
|
||||
Gruppen erhalten es beim LiteDB-Deserialisieren automatisch, keine explizite Migration
|
||||
nötig) über die `TimetableSlot`-Einträge von heute bis morgen
|
||||
(`UnplannedLessonsLookaheadDays = 1`, Nutzer hat sich im Dialog explizit für "Heute +
|
||||
morgen" statt eines längeren Vorgriffs entschieden) und meldet jeden Termin, zu dem entweder
|
||||
keine `Lesson` existiert oder deren `Topic` leer ist. Ferientage/Feiertage werden wie überall
|
||||
sonst übersprungen (`IsFreeDay`, dieselbe Prüfung wie in `TimetableViewModel`/
|
||||
`GenerateLessonSeriesDialogViewModel` — bewusst dupliziert statt in einen gemeinsamen
|
||||
Service extrahiert, konsistent mit dem bestehenden Muster). Klick auf einen Eintrag springt
|
||||
in die Lerngruppe, Tab "Planung" (`OnNavigateToUnplannedLesson`,
|
||||
`NavigateToGroupDetail(id, 6)`), damit das Thema direkt ergänzt werden kann — bewusst
|
||||
anderes Sprungziel als die "Heutige Stunden"-Kachel (9.2, Tab "Mitarbeit"). Deaktivierbar je
|
||||
Gruppe über eine neue Checkbox "Benötigt Unterrichtsplanung" im Gruppen-Stammdaten-Dialog.
|
||||
|
||||
**Nachtrag zu 9.9 (Bugfix Doppelstunden-Erkennung):** Nutzer-Bug-Report: eine als Doppelstunde
|
||||
geplante 3./4. Stunde (eine `Lesson` mit `LessonNumber = 3` und Thema, die laut Stundenraster
|
||||
90 Minuten abdeckt, siehe Nachtrag zu 4.2.2) wurde auf dem Dashboard trotzdem als "4. Stunde noch
|
||||
ungeplant" gemeldet, weil `LoadUnplannedLessons` je `TimetableSlot` stur auf eine `Lesson` mit
|
||||
exakt derselben `LessonNumber` prüfte — für Periode 4 gibt es bei einer Doppelstunde aber bewusst
|
||||
keine eigene `Lesson`. Neue Hilfsmethode `IsCoveredByEarlierDoppelstunde` wendet dieselbe
|
||||
Rückwärts-Erkennung wie `LessonDialogViewModel.RecomputeTimeBudget` an: ausgehend von der
|
||||
Vorperiode wird so lange rückwärts geprüft, wie der Stundenplan dort ebenfalls einen Slot hat;
|
||||
trifft man auf eine `Lesson` mit Thema, gilt die Periode als abgedeckt, trifft man auf eine
|
||||
`Lesson` ohne Thema, bricht die Suche ab (diese Periode bleibt eine eigene, ungeplante Stunde).
|
||||
Eine eigene, direkt zugeordnete `Lesson` ohne Thema wird also weiterhin gemeldet — nur die
|
||||
implizit mitabgedeckte Folgeperiode einer bereits geplanten Doppelstunde nicht mehr.
|
||||
|
||||
**Nachtrag zu 9.9 (Bugfix Stundenausfall):** Zweiter Nutzer-Bug-Report: eine laut Stundenplan
|
||||
regulär stattfindende, aber per Vertretungs-Dialog als `SubstitutionKind.Cancelled` eingetragene
|
||||
Stunde (4.3, "Stundenausfall") wurde trotzdem als ungeplant gemeldet, obwohl sie an diesem Tag
|
||||
gar nicht stattfindet. `LoadUnplannedLessons` prüft jetzt zusätzlich `ISubstitutionEntryRepository.
|
||||
GetByDate(date)` auf `Cancelled`-Einträge und überspringt betroffene Stundennummern vollständig —
|
||||
gleiche Abfrage (Datum + Stundennummer, bewusst ohne Gruppenbezug, siehe `SubstitutionEntry.
|
||||
PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des
|
||||
Stundenplans.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user