Unterrichtsplanung: Serienerzeugung, Stundenraster, Aufsichten & Vertretung (Kapitel 4.2/4.3 Nachtrag)

Stunden serienweise aus dem Stundenplan erzeugen (4.2.5); Stundenraster
(Uhrzeiten je Stunde) in den Einstellungen mit Zeitbedarf-Rückmeldung im
Verlaufsplan-Editor; wiederkehrende Pausenaufsicht; neuer "Vertretung
eintragen"-Dialog für einmalige Vertretungsaufsicht, Vertretungsstunde,
Sondereinsätze (Ausflüge, Berufsmessen) und schlichten Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 21:59:07 +02:00
co-authored by Claude Sonnet 5
parent 99b2de6519
commit d6dee72acc
30 changed files with 2743 additions and 48 deletions
@@ -2,16 +2,24 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Ergebnisse der Verschieben-/Kopieren-Dialoge (4.2.4 / 4.1.4) ─────────────
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing);
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
{
public string Summary => $"{Created} Stunde(n) angelegt" +
(SkippedHoliday > 0 ? $", {SkippedHoliday} durch Ferien/Feiertage übersprungen" : "") +
(SkippedExisting > 0 ? $", {SkippedExisting} bereits vorhanden" : "") + ".";
}
// ── Tab-ViewModel: Unterrichtsplanung (4.1 Einheiten / 4.2 Einzelstunden) ────
public partial class PlanningTabViewModel : ObservableObject
@@ -56,6 +64,7 @@ public partial class PlanningTabViewModel : ObservableObject
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
public Func<Lesson, Task>? OnShowLesson { get; set; }
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects,
@@ -109,6 +118,7 @@ public partial class PlanningTabViewModel : ObservableObject
DeleteUnitCommand.NotifyCanExecuteChanged();
CopyUnitCommand.NotifyCanExecuteChanged();
AddLessonCommand.NotifyCanExecuteChanged();
GenerateLessonSeriesCommand.NotifyCanExecuteChanged();
}
private void LoadLessons()
@@ -231,6 +241,16 @@ public partial class PlanningTabViewModel : ObservableObject
if (await OnAddLesson(SelectedUnit.Id, _groupId, KnownMaterials, KnownShorthands)) LoadUnits();
}
/// Serienerzeugung von Stunden aus dem Stundenplan (4.2.5) — die eigentliche Logik läuft im
/// Dialog (<see cref="GenerateLessonSeriesDialogViewModel"/>), hier wird nur nachgeladen.
[RelayCommand(CanExecute = nameof(HasSelectedUnit))]
private async Task GenerateLessonSeries()
{
if (OnGenerateLessonSeries is null || SelectedUnit is null) return;
var result = await OnGenerateLessonSeries(SelectedUnit.Model);
if (result is not null) LoadUnits();
}
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private async Task EditLesson()
{
@@ -558,6 +578,8 @@ public partial class LessonDialogViewModel : ObservableObject
{
private readonly ILessonRepository _lessons;
private readonly IAlternativeLessonPathRepository _alternativePaths;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule;
private readonly Guid _unitId;
private readonly Guid _groupId;
private readonly Lesson? _editingLesson;
@@ -573,6 +595,9 @@ public partial class LessonDialogViewModel : ObservableObject
[ObservableProperty] private string _topicError = "";
[ObservableProperty] private string _startTimeTextError = "";
[ObservableProperty] private string _totalDurationDisplay = "0 Minuten gesamt";
[ObservableProperty] private string _timeBudgetLabel = "";
[ObservableProperty] private string _timeBudgetColorHex = "#9E9E9E";
[ObservableProperty] private bool _hasTimeBudgetInfo;
public string[] StatusOptions => LessonStatusDisplay.Options;
public string[] MaterialSuggestions { get; }
@@ -589,10 +614,12 @@ public partial class LessonDialogViewModel : ObservableObject
public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern";
public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes,
IAlternativeLessonPathRepository alternativePaths, Guid unitId, Guid groupId,
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, Guid unitId, Guid groupId,
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
{
_lessons = lessons; _alternativePaths = alternativePaths;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
MaterialSuggestions = [.. materialSuggestions];
@@ -672,6 +699,18 @@ public partial class LessonDialogViewModel : ObservableObject
}
partial void OnStartTimeTextChanged(string value) => RecomputeTimes();
/// Übernimmt beim Wählen der Stundennummer auch gleich den Beginn aus dem Stundenraster
/// (Einstellungen), sofern noch keiner eingetragen ist — beim Laden einer vorhandenen Stunde
/// wird das direkt danach vom tatsächlich gespeicherten <c>StartTime</c> überschrieben (auch
/// wenn das "kein Beginn hinterlegt" bedeutet), ein bereits eingetippter Beginn bleibt unangetastet.
partial void OnLessonNumberChanged(int? value)
{
if (value is int period && string.IsNullOrWhiteSpace(StartTimeText) &&
_periodSchedule.GetTimes(period) is { } times)
StartTimeText = times.Start.ToString("HH:mm");
RecomputeTimes();
}
partial void OnDateTextChanged(string value) => RecomputeTimes();
/// Dauer ist die primäre Eingabe je Phase; die Uhrzeit wird daraus nur zur Anzeige
/// abgeleitet — kumulativ ab "Beginn", sofern gesetzt (sonst bleibt sie leer).
@@ -690,8 +729,61 @@ public partial class LessonDialogViewModel : ObservableObject
p.ComputedTimeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : "";
if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes);
}
RecomputeTimeBudget(total);
}
/// <summary>
/// Vergleicht die geplante Gesamtdauer mit der laut Stundenraster (Einstellungen) tatsächlich
/// verfügbaren Zeit — Doppelstunden werden erkannt, indem ab der eingetragenen Stundennummer
/// so lange die jeweils nächste Periode addiert wird, wie der Stundenplan (4.3) für dieselbe
/// Gruppe/denselben Wochentag auch dort einen Slot hat (siehe <see cref="TimetableSlot"/>).
/// Ohne erkennbare Stunde/Datum oder ohne im Stundenraster hinterlegte Uhrzeiten bleibt die
/// Rückmeldung schlicht ausgeblendet, statt eine erfundene Dauer vorzutäuschen.
/// </summary>
private void RecomputeTimeBudget(int plannedMinutes)
{
if (LessonNumber is not int startPeriod ||
!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{ HasTimeBudgetInfo = false; return; }
var weekday = date.DayOfWeek;
var groupSlotsByPeriod = _timetableSlots.GetByGroup(_groupId)
.Where(s => s.Weekday == weekday)
.ToDictionary(s => s.PeriodNumber);
var available = _periodSchedule.GetDurationMinutes(startPeriod);
var period = startPeriod + 1;
while (groupSlotsByPeriod.ContainsKey(period))
{
available += _periodSchedule.GetDurationMinutes(period);
period++;
}
if (available <= 0) { HasTimeBudgetInfo = false; return; }
var utilizationPercent = (double)plannedMinutes / available * 100;
TimeBudgetColorHex = TimeBudgetColor(utilizationPercent);
TimeBudgetLabel = $"{plannedMinutes} von {available} Minuten geplant ({utilizationPercent:0}%)";
HasTimeBudgetInfo = true;
}
/// <summary>
/// Farbskala für die Auslastung (geplante / verfügbare Minuten): 9396 % gilt als guter
/// Zielbereich (grün) — ein kleiner Puffer, da 100 % erfahrungsgemäß schon knapp ist. Darüber
/// wird es zunehmend rötlich, deutlich über 100 % kräftig rot. Deutlich unter 93 % (zu viel
/// Luft) ist bewusst neutral/blau statt rot gehalten — kein Fehler, nur "hier geht noch was".
/// </summary>
private static string TimeBudgetColor(double utilizationPercent) => utilizationPercent switch
{
< 70 => "#90A4AE", // Blaugrau: deutlich zu wenig geplant
< 93 => "#FFC107", // Gelb: noch Luft nach oben
<= 96 => "#43A047", // Grün: guter Zielbereich
<= 100 => "#FB8C00", // Orange: knapp, kaum Puffer
<= 115 => "#E64A19", // Rotorange: leicht überplant
_ => "#B71C1C", // Dunkelrot: deutlich überplant
};
[RelayCommand]
private void Save()
{
@@ -886,6 +978,93 @@ public partial class MoveLessonDialogViewModel : ObservableObject
}
}
// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
{
private readonly ITimetableSlotRepository _slots;
private readonly ILessonRepository _lessons;
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly Guid _unitId;
private readonly Guid _groupId;
[ObservableProperty] private string _fromDateText;
[ObservableProperty] private string _toDateText;
[ObservableProperty] private string _dateError = "";
public LessonSeriesResult? Result { get; private set; }
public GenerateLessonSeriesDialogViewModel(ITimetableSlotRepository slots, ILessonRepository lessons,
ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays,
SchoolCalendarSettingsService calendarSettings, Guid unitId, Guid groupId,
DateOnly? defaultFrom, DateOnly? defaultTo)
{
_slots = slots; _lessons = lessons; _schoolHolidays = schoolHolidays;
_publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_unitId = unitId; _groupId = groupId;
_fromDateText = (defaultFrom ?? DateOnly.FromDateTime(DateTime.Today)).ToString("dd.MM.yyyy");
_toDateText = (defaultTo ?? DateOnly.FromDateTime(DateTime.Today).AddMonths(1)).ToString("dd.MM.yyyy");
}
/// Legt für jeden Wochentag/Stunde, den die Gruppe laut Stundenplan (4.3) hat, im gewählten
/// Zeitraum eine neue Lesson an. Schulferien/Feiertage werden übersprungen (dieselbe Prüfung
/// wie im Stundenplan-Wochenraster, siehe TimetableViewModel.IsFreeDay); für ein Datum, an dem
/// die Gruppe laut Stundenplan bereits eine Lesson hat (gleiches Datum + gleiche Stundennummer,
/// unabhängig von der Einheit — ein Lehrer kann an einem Termin nur eine tatsächliche Stunde
/// halten), wird nichts doppelt angelegt. Neue Stunden bekommen bewusst kein Thema — die
/// sonst übliche "Thema erforderlich"-Regel des manuellen "+Stunde"-Dialogs gilt hier nicht,
/// da diese Platzhalter zum späteren Ausfüllen gedacht sind.
[RelayCommand]
private void Save()
{
DateError = "";
if (!DateOnly.TryParseExact(FromDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) ||
!DateOnly.TryParseExact(ToDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to))
{ DateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; return; }
if (to < from) { DateError = "Das Ende darf nicht vor dem Beginn liegen."; return; }
var slotsForGroup = _slots.GetByGroup(_groupId);
if (slotsForGroup.Count == 0)
{ DateError = "Für diese Gruppe ist noch keine Stunde im Stundenplan eingetragen."; return; }
var schoolHolidays = _schoolHolidays.GetAll();
var publicHolidayDates = new HashSet<DateOnly>();
for (var year = from.Year; year <= to.Year; year++)
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date);
var existing = _lessons.GetByGroupAndRange(_groupId, from, to)
.Select(l => (l.Date, l.LessonNumber)).ToHashSet();
int created = 0, skippedHoliday = 0, skippedExisting = 0;
for (var date = from; date <= to; date = date.AddDays(1))
{
var isFreeDay = publicHolidayDates.Contains(date) ||
schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
foreach (var slot in slotsForGroup.Where(s => s.Weekday == date.DayOfWeek))
{
if (isFreeDay) { skippedHoliday++; continue; }
if (existing.Contains((date, (int?)slot.PeriodNumber))) { skippedExisting++; continue; }
_lessons.Save(new Lesson
{
UnitId = _unitId,
GroupId = _groupId,
Date = date,
LessonNumber = slot.PeriodNumber,
Topic = "",
});
created++;
}
}
Result = new LessonSeriesResult(created, skippedHoliday, skippedExisting);
}
}
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
public partial class CopyUnitDialogViewModel : ObservableObject