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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user