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:
@@ -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): 93–96 % 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
|
||||
|
||||
Reference in New Issue
Block a user