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
@@ -0,0 +1,176 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Planning;
public static class SubstitutionKindDisplay
{
public static string[] Options { get; } = ["Aufsicht", "Stunde", "Sondereinsatz", "Ausfall"];
public static string ToName(SubstitutionKind k) => k switch
{
SubstitutionKind.Lesson => "Stunde",
SubstitutionKind.SpecialAssignment => "Sondereinsatz",
SubstitutionKind.Cancelled => "Ausfall",
_ => "Aufsicht",
};
public static SubstitutionKind FromName(string? name) => name switch
{
"Stunde" => SubstitutionKind.Lesson,
"Sondereinsatz" => SubstitutionKind.SpecialAssignment,
"Ausfall" => SubstitutionKind.Cancelled,
_ => SubstitutionKind.Supervision,
};
}
/// <summary>
/// Dialog "Vertretung eintragen" (4.3 Nachtrag, Sonderfälle): einmalige Vertretungsaufsicht,
/// Vertretungsstunde in einer eigenen oder fremden Lerngruppe, ein Sondereinsatz (Ausflug,
/// Berufsmesse, Exkursion, ...), oder ein schlichter Stundenausfall (z.B. weil die betroffene
/// Gruppe selbst auf Klassenfahrt ist — nicht die eigene Abwesenheit, sondern die der Gruppe).
/// Bei eigener Gruppe bleibt der einfache Weg (nur Plan-Eintrag mit Thema) der Standard —
/// "Direkt als Stunde in der Einheit übernehmen" ist ein bewusstes Opt-in, nur bei
/// Vertretungsstunden, damit die meist beiläufigen Vertretungsstunden nicht automatisch die
/// Fortschrittsanzeige/Reihenfolge der Einheit durcheinanderbringen (siehe TODO.md, Nachtrag zu
/// 4.3). Sondereinsatz und Ausfall sind bewusst nie als Einheiten-Stunde übernehmbar — ein
/// Ausflug ist inhaltlich kein Verlaufsplan-Eintrag, und ein Ausfall ist per Definition keine
/// gehaltene Stunde.
/// </summary>
public partial class SubstitutionEntryDialogViewModel : ObservableObject
{
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IUnitRepository _units;
private readonly ILessonRepository _lessons;
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _kindName = SubstitutionKindDisplay.Options[0];
[ObservableProperty] private int _afterPeriod;
[ObservableProperty] private int _periodNumber = 1;
[ObservableProperty] private bool _isAllDay = true;
[ObservableProperty] private int _fromPeriod = 1;
[ObservableProperty] private int _toPeriod = 10;
[ObservableProperty] private LearningGroup? _selectedOwnGroup;
[ObservableProperty] private string _groupLabel = "";
[ObservableProperty] private string _description = "";
[ObservableProperty] private bool _promoteToLesson;
[ObservableProperty] private Unit? _selectedUnit;
[ObservableProperty] private string _dateError = "";
[ObservableProperty] private string _validationError = "";
public string[] KindOptions => SubstitutionKindDisplay.Options;
public bool IsSupervisionKind => KindName == "Aufsicht";
public bool IsLessonKind => KindName == "Stunde";
public bool IsSpecialAssignmentKind => KindName == "Sondereinsatz";
public bool IsCancelledKind => KindName == "Ausfall";
public bool CanPromoteToLesson => IsLessonKind && SelectedOwnGroup is not null && UnitsOfSelectedGroup.Count > 0;
public ObservableCollection<LearningGroup> OwnGroups { get; } = [];
public ObservableCollection<Unit> UnitsOfSelectedGroup { get; } = [];
public SubstitutionEntry? Result { get; private set; }
public SubstitutionEntryDialogViewModel(ISubstitutionEntryRepository substitutions, IGroupRepository groups,
IUnitRepository units, ILessonRepository lessons)
{
_substitutions = substitutions; _units = units; _lessons = lessons;
foreach (var g in groups.GetAll().OrderBy(g => g.Name)) OwnGroups.Add(g);
}
partial void OnKindNameChanged(string value)
{
OnPropertyChanged(nameof(IsSupervisionKind));
OnPropertyChanged(nameof(IsLessonKind));
OnPropertyChanged(nameof(IsSpecialAssignmentKind));
OnPropertyChanged(nameof(IsCancelledKind));
OnPropertyChanged(nameof(CanPromoteToLesson));
}
partial void OnSelectedOwnGroupChanged(LearningGroup? value)
{
UnitsOfSelectedGroup.Clear();
if (value is not null)
{
GroupLabel = value.Name;
foreach (var u in _units.GetByGroup(value.Id)) UnitsOfSelectedGroup.Add(u);
}
SelectedUnit = UnitsOfSelectedGroup.FirstOrDefault();
PromoteToLesson = false;
OnPropertyChanged(nameof(CanPromoteToLesson));
}
[RelayCommand]
private void Save()
{
DateError = ""; ValidationError = "";
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
{ DateError = "Format TT.MM.JJJJ."; return; }
var kind = SubstitutionKindDisplay.FromName(KindName);
var entry = new SubstitutionEntry { Date = date, Kind = kind };
switch (kind)
{
case SubstitutionKind.Supervision:
if (AfterPeriod is < 0 or > 10) { ValidationError = "Pause muss zwischen 0 und 10 liegen."; return; }
if (string.IsNullOrWhiteSpace(Description)) { ValidationError = "Grund/Ort erforderlich."; return; }
entry.AfterPeriod = AfterPeriod;
entry.Description = Description.Trim();
break;
case SubstitutionKind.Lesson:
if (PeriodNumber is < 1 or > 10) { ValidationError = "Stunde muss zwischen 1 und 10 liegen."; return; }
if (string.IsNullOrWhiteSpace(GroupLabel)) { ValidationError = "Gruppe/Bezeichnung erforderlich."; return; }
if (string.IsNullOrWhiteSpace(Description)) { ValidationError = "Thema erforderlich."; return; }
entry.PeriodNumber = PeriodNumber;
entry.GroupId = SelectedOwnGroup?.Id;
entry.GroupLabel = GroupLabel.Trim();
entry.Description = Description.Trim();
break;
case SubstitutionKind.SpecialAssignment:
if (string.IsNullOrWhiteSpace(Description)) { ValidationError = "Bezeichnung erforderlich."; return; }
if (!IsAllDay)
{
if (FromPeriod is < 1 or > 10 || ToPeriod is < 1 or > 10)
{ ValidationError = "Stunden müssen zwischen 1 und 10 liegen."; return; }
if (ToPeriod < FromPeriod)
{ ValidationError = "Die Bis-Stunde darf nicht vor der Von-Stunde liegen."; return; }
entry.FromPeriod = FromPeriod;
entry.ToPeriod = ToPeriod;
}
entry.IsAllDay = IsAllDay;
entry.GroupId = SelectedOwnGroup?.Id;
entry.GroupLabel = GroupLabel.Trim();
entry.Description = Description.Trim();
break;
case SubstitutionKind.Cancelled:
if (PeriodNumber is < 1 or > 10) { ValidationError = "Stunde muss zwischen 1 und 10 liegen."; return; }
entry.PeriodNumber = PeriodNumber;
entry.Description = Description.Trim();
break;
}
_substitutions.Save(entry);
if (kind == SubstitutionKind.Lesson && PromoteToLesson && SelectedOwnGroup is not null && SelectedUnit is not null)
{
_lessons.Save(new Lesson
{
UnitId = SelectedUnit.Id,
GroupId = SelectedOwnGroup.Id,
Date = date,
LessonNumber = PeriodNumber,
Topic = Description.Trim(),
});
}
Result = entry;
}
}
@@ -58,11 +58,15 @@ public partial class TimetableViewModel : ObservableObject
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolYearService _schoolYear;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly ISubstitutionEntryRepository _substitutions;
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
public ObservableCollection<HoursWarningItem> HoursWarnings { get; } = [];
public ObservableCollection<TodayLessonItem> TodayItems { get; } = [];
public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = [];
public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = [];
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _todayLabel = "";
@@ -72,15 +76,18 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; }
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear)
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions)
{
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
Load();
}
@@ -95,6 +102,14 @@ public partial class TimetableViewModel : ObservableObject
[RelayCommand]
private void CurrentWeek() { WeekOffset = 0; Load(); }
[RelayCommand]
private async Task AddSubstitution()
{
if (OnAddSubstitution is null) return;
await OnAddSubstitution();
Load();
}
public void Load()
{
var today = DateOnly.FromDateTime(DateTime.Today);
@@ -106,33 +121,90 @@ public partial class TimetableViewModel : ObservableObject
var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates);
var examProximity = ComputeExamProximity(today, publicHolidayDates);
var duties = _supervisionDuties.GetAll();
BuildGrid(holidayBadges);
BuildWeekOverview(today, publicHolidayDates);
BuildGrid(holidayBadges, duties);
BuildWeekOverview(today, publicHolidayDates, duties);
BuildToday(today);
BuildHoursWarnings();
}
// ── "Heute": Tagesliste ──────────────────────────────────────────────────
/// <summary>
/// Vertretungsstunden (<see cref="SubstitutionEntry"/>, Kind Lesson) überschreiben für ihre
/// Stunde die normale, aus dem Stundenplan abgeleitete Anzeige — sie beschreiben, was an
/// diesem konkreten Tag tatsächlich stattfindet. Stunden ohne passenden Stundenplan-Slot (z.B.
/// Vertretung in einer fremden Gruppe) werden zusätzlich angehängt.
/// </summary>
private void BuildToday(DateOnly today)
{
TodayItems.Clear();
var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).OrderBy(s => s.PeriodNumber).ToList();
var substitutionsToday = _substitutions.GetByDate(today);
var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).ToList();
var items = new List<TodayLessonItem>();
var coveredPeriods = new HashSet<int>();
foreach (var slot in slotsToday)
{
coveredPeriods.Add(slot.PeriodNumber);
var lessonSub = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Lesson && s.PeriodNumber == slot.PeriodNumber);
if (lessonSub is not null) { items.Add(TodayLessonItem.ForSubstitution(slot.PeriodNumber, lessonSub)); continue; }
var group = _groups.GetById(slot.GroupId);
if (group is null) continue;
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
TodayItems.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title));
}
foreach (var sub in substitutionsToday.Where(s => s.Kind == SubstitutionKind.Lesson &&
s.PeriodNumber is int p && !coveredPeriods.Contains(p)))
items.Add(TodayLessonItem.ForSubstitution(sub.PeriodNumber!.Value, sub));
TodayItems.Clear();
foreach (var item in items.OrderBy(i => i.PeriodNumber)) TodayItems.Add(item);
TodaySupervisions.Clear();
foreach (var item in BuildTodaySupervisionItems(today, substitutionsToday)) TodaySupervisions.Add(item);
TodaySpecialAssignments.Clear();
foreach (var sub in substitutionsToday.Where(s => s.Kind == SubstitutionKind.SpecialAssignment))
{
var periodLabel = sub.IsAllDay ? "Ganztägig" : $"{sub.FromPeriod}.{sub.ToPeriod}. Stunde";
TodaySpecialAssignments.Add(new TodaySpecialAssignmentItem(periodLabel, sub.Description, sub.GroupLabel));
}
}
private List<TodaySupervisionItem> BuildTodaySupervisionItems(DateOnly today, List<SubstitutionEntry> substitutionsToday)
{
var dutiesToday = _supervisionDuties.GetAll().Where(d => d.Weekday == today.DayOfWeek).ToList();
var subsToday = substitutionsToday.Where(s => s.Kind == SubstitutionKind.Supervision).ToList();
var afterPeriods = dutiesToday.Select(d => d.AfterPeriod)
.Concat(subsToday.Select(s => s.AfterPeriod!.Value))
.Distinct().OrderBy(p => p);
var result = new List<TodaySupervisionItem>();
foreach (var afterPeriod in afterPeriods)
{
var sub = subsToday.FirstOrDefault(s => s.AfterPeriod == afterPeriod);
if (sub is not null) { result.Add(new TodaySupervisionItem(afterPeriod, sub.Description, isSubstitution: true)); continue; }
var duty = dutiesToday.First(d => d.AfterPeriod == afterPeriod);
result.Add(new TodaySupervisionItem(afterPeriod, duty.Location, isSubstitution: false));
}
return result;
}
[RelayCommand]
private void OpenGroup(Guid groupId) => OnNavigateToGroup?.Invoke(groupId);
private void OpenGroup(Guid groupId)
{
if (groupId == Guid.Empty) return; // Vertretung in fremder Gruppe ohne echte GroupId
OnNavigateToGroup?.Invoke(groupId);
}
[RelayCommand]
private void ShowEditor() => ActiveTabIndex = 1;
@@ -146,9 +218,11 @@ public partial class TimetableViewModel : ObservableObject
/// parallele Kurs oder die nächste Stunde in der Woche auf einen Blick sichtbar sind. Die
/// Badges (Ferien-Nähe, Klausur-Nähe) werden je Zelle am dort angezeigten Datum ausgerichtet,
/// nicht am realen "heute" — sonst würde beim Blättern in andere Wochen ein Badge angezeigt,
/// das eigentlich zu einer ganz anderen Woche gehört.
/// das eigentlich zu einer ganz anderen Woche gehört. Aufsicht-Zeilen (wiederkehrend + einmalige
/// Vertretungsaufsicht) werden zwischen den betroffenen Stundenzeilen eingefügt, Vertretungsstunden
/// ersetzen für ihre Stunde die sonst aus dem Stundenplan abgeleitete Anzeige.
/// </summary>
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates)
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates, List<SupervisionDuty> duties)
{
WeekItems.Clear();
var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
@@ -160,22 +234,54 @@ public partial class TimetableViewModel : ObservableObject
var groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id);
var schoolHolidays = _schoolHolidays.GetAll();
var dateByWeekday = Weekdays.ToDictionary(w => w, w => monday.AddDays((int)w - (int)DayOfWeek.Monday));
var dutiesByPeriod = duties.ToLookup(d => d.AfterPeriod);
var substitutionsThisWeek = dateByWeekday.Values.SelectMany(d => _substitutions.GetByDate(d)).ToList();
WeekItems.Add(WeekCellItem.Corner());
foreach (var weekday in Weekdays)
WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today));
AddWeekSupervisionRowIfAny(0, dutiesByPeriod, substitutionsThisWeek, dateByWeekday);
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
WeekItems.Add(WeekCellItem.PeriodLabel(period));
foreach (var weekday in Weekdays)
{
var date = dateByWeekday[weekday];
var lessonSub = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.Lesson && s.Date == date && s.PeriodNumber == period);
if (lessonSub is not null)
{
WeekItems.Add(WeekCellItem.ForSubstitutionLesson(weekday, period, date == today, lessonSub));
continue;
}
var specialAssignment = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.SpecialAssignment && s.Date == date &&
(s.IsAllDay || (period >= s.FromPeriod && period <= s.ToPeriod)));
if (specialAssignment is not null)
{
WeekItems.Add(WeekCellItem.ForSpecialAssignment(weekday, period, date == today, specialAssignment));
continue;
}
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; }
var date = dateByWeekday[weekday];
var group = groups.GetValueOrDefault(slot.GroupId);
var subject = group?.SubjectId is { } subjectId ? _subjects.GetById(subjectId) : null;
var cancelled = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.Cancelled && s.Date == date && s.PeriodNumber == period);
if (cancelled is not null)
{
WeekItems.Add(WeekCellItem.ForCancelled(weekday, period, date == today, cancelled,
subject?.ShortName is { Length: > 0 } csn ? csn : subject?.Name ?? "",
group?.Name ?? "?", slot.GroupId));
continue;
}
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
@@ -189,6 +295,30 @@ public partial class TimetableViewModel : ObservableObject
colorHex, holidayBadge, hasExam, isLastBeforeExam,
MentionsExperiment(lesson), slot.GroupId, isHoliday));
}
AddWeekSupervisionRowIfAny(period, dutiesByPeriod, substitutionsThisWeek, dateByWeekday);
}
}
private void AddWeekSupervisionRowIfAny(int afterPeriod, ILookup<int, SupervisionDuty> dutiesByPeriod,
List<SubstitutionEntry> substitutionsThisWeek, Dictionary<DayOfWeek, DateOnly> dateByWeekday)
{
var hasAny = dutiesByPeriod.Contains(afterPeriod) ||
substitutionsThisWeek.Any(s => s.Kind == SubstitutionKind.Supervision && s.AfterPeriod == afterPeriod);
if (!hasAny) return;
WeekItems.Add(WeekCellItem.SupervisionRowLabel(afterPeriod));
foreach (var weekday in Weekdays)
{
var date = dateByWeekday[weekday];
var substitution = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.Supervision && s.Date == date && s.AfterPeriod == afterPeriod);
if (substitution is not null)
{
WeekItems.Add(WeekCellItem.SupervisionCell(substitution.Description, isSubstitution: true));
continue;
}
var duty = dutiesByPeriod[afterPeriod].FirstOrDefault(d => d.Weekday == weekday);
WeekItems.Add(WeekCellItem.SupervisionCell(duty?.Location ?? "", isSubstitution: false));
}
}
@@ -226,16 +356,19 @@ public partial class TimetableViewModel : ObservableObject
// ── Bearbeiten-Raster ─────────────────────────────────────────────────────
private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges)
private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges, List<SupervisionDuty> duties)
{
Cells.Clear();
var allSlots = _slots.GetAll();
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
var dutiesByPeriod = duties.ToLookup(d => d.AfterPeriod);
Cells.Add(TimetableCellItem.Corner());
foreach (var weekday in Weekdays)
Cells.Add(TimetableCellItem.WeekdayHeader(weekday));
AddGridSupervisionRowIfAny(0, dutiesByPeriod);
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
Cells.Add(TimetableCellItem.PeriodLabel(period));
@@ -246,6 +379,21 @@ public partial class TimetableViewModel : ObservableObject
var badge = slot is not null ? badges.GetValueOrDefault((weekday, slot.GroupId), "") : "";
Cells.Add(TimetableCellItem.ForSlot(weekday, period, slot, groupName, ColorFor(groupName), badge));
}
AddGridSupervisionRowIfAny(period, dutiesByPeriod);
}
}
/// <summary>Aufsicht wird nur in den Einstellungen gepflegt (siehe SettingsView) — im
/// Bearbeiten-Raster ist die Zeile bewusst nur Anzeige, kein weiterer Klick-Dialog nötig für
/// eine so kleine, seltene Pflegeaufgabe.</summary>
private void AddGridSupervisionRowIfAny(int afterPeriod, ILookup<int, SupervisionDuty> dutiesByPeriod)
{
if (!dutiesByPeriod.Contains(afterPeriod)) return;
Cells.Add(TimetableCellItem.SupervisionRowLabel(afterPeriod));
foreach (var weekday in Weekdays)
{
var duty = dutiesByPeriod[afterPeriod].FirstOrDefault(d => d.Weekday == weekday);
Cells.Add(TimetableCellItem.SupervisionCell(duty?.Location ?? ""));
}
}
@@ -345,6 +493,8 @@ public class TimetableCellItem
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
public bool IsSupervisionRow { get; private init; }
public bool IsSupervisionCell { get; private init; }
public string Text { get; private init; } = "";
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
@@ -352,9 +502,11 @@ public class TimetableCellItem
public string GroupName { get; private init; } = "";
public string ColorHex { get; private init; } = "#9E9E9E";
public string BadgeText { get; private init; } = "";
public string SupervisionLocation { get; private init; } = "";
public bool HasSupervision => SupervisionLocation.Length > 0;
public bool HasBadge => BadgeText.Length > 0;
public bool IsAssigned => Slot is not null;
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
public bool IsSlotCell => !IsHeader && !IsPeriodLabel && !IsSupervisionRow && !IsSupervisionCell;
public static TimetableCellItem Corner() => new() { IsHeader = true, Text = "" };
@@ -370,6 +522,18 @@ public class TimetableCellItem
public static TimetableCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
public static TimetableCellItem SupervisionRowLabel(int afterPeriod) => new()
{
IsSupervisionRow = true,
Text = afterPeriod == 0 ? "Aufsicht (vor 1.)" : $"Aufsicht (n. {afterPeriod}.)",
};
public static TimetableCellItem SupervisionCell(string location) => new()
{
IsSupervisionCell = true,
SupervisionLocation = location,
};
public static TimetableCellItem ForSlot(DayOfWeek day, int period, TimetableSlot? slot, string groupName,
string colorHex, string badgeText) => new()
{
@@ -384,8 +548,14 @@ public class WeekCellItem
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
public bool IsSupervisionRow { get; private init; }
public bool IsSupervisionCell { get; private init; }
public bool IsSlotCell => !IsHeader && !IsPeriodLabel && !IsSupervisionRow && !IsSupervisionCell;
public bool IsAssigned { get; private init; }
public bool IsSubstitutionLesson { get; private init; }
public bool IsSubstitutionSupervision { get; private init; }
public bool IsSpecialAssignment { get; private init; }
public bool IsCancelled { get; private init; }
public string Text { get; private init; } = "";
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
@@ -402,6 +572,8 @@ public class WeekCellItem
public bool IsLastBeforeExam { get; private init; }
public bool HasExperiment { get; private init; }
public bool IsHoliday { get; private init; }
public string SupervisionLocation { get; private init; } = "";
public bool HasSupervision => SupervisionLocation.Length > 0;
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
@@ -422,6 +594,19 @@ public class WeekCellItem
public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period };
public static WeekCellItem SupervisionRowLabel(int afterPeriod) => new()
{
IsSupervisionRow = true,
Text = afterPeriod == 0 ? "Aufsicht (vor 1.)" : $"Aufsicht (n. {afterPeriod}.)",
};
public static WeekCellItem SupervisionCell(string location, bool isSubstitution) => new()
{
IsSupervisionCell = true,
SupervisionLocation = location,
IsSubstitutionSupervision = isSubstitution,
};
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
string groupName, string room, string topic, string colorHex, string holidayBadge,
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday) => new()
@@ -432,6 +617,30 @@ public class WeekCellItem
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
IsHoliday = isHoliday,
};
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
SubjectLabel = "Vertretung", GroupName = entry.GroupLabel, Topic = entry.Description,
ColorHex = "#8E24AA", GroupId = entry.GroupId ?? Guid.Empty,
};
public static WeekCellItem ForSpecialAssignment(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSpecialAssignment = true,
SubjectLabel = "Sondereinsatz",
GroupName = string.IsNullOrWhiteSpace(entry.GroupLabel) ? "" : entry.GroupLabel,
Topic = entry.Description,
ColorHex = "#00838F", GroupId = entry.GroupId ?? Guid.Empty,
};
public static WeekCellItem ForCancelled(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry,
string subjectLabel, string groupName, Guid groupId) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsCancelled = true,
SubjectLabel = subjectLabel, GroupName = groupName, Topic = entry.Description,
ColorHex = "#757575", GroupId = groupId,
};
}
public class HoursWarningItem(string groupName, int assigned, int expected)
@@ -440,17 +649,53 @@ public class HoursWarningItem(string groupName, int assigned, int expected)
public string Text { get; } = $"{groupName}: {assigned} von {expected} Wochenstunden eingetragen";
}
public class TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
string colorHex, string? lessonTopic, string? examTitle)
public class TodayLessonItem
{
public Guid GroupId { get; } = groupId;
public int PeriodNumber { get; } = periodNumber;
public string GroupName { get; } = groupName;
public string Room { get; } = room;
public string ColorHex { get; } = colorHex;
public string? LessonTopic { get; } = lessonTopic;
public string? ExamTitle { get; } = examTitle;
public Guid GroupId { get; private init; }
public int PeriodNumber { get; private init; }
public string GroupName { get; private init; } = "";
public string Room { get; private init; } = "";
public string ColorHex { get; private init; } = "#9E9E9E";
public string? LessonTopic { get; private init; }
public string? ExamTitle { get; private init; }
public bool IsSubstitution { get; private init; }
public bool IsCancelled { get; private init; }
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic);
public bool HasExam => ExamTitle is not null;
public bool HasGroupId => GroupId != Guid.Empty;
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
string colorHex, string? lessonTopic, string? examTitle)
{
GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
}
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
{ IsSubstitution = true };
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
groupId, periodNumber, groupName, "", "#757575",
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
{ IsCancelled = true };
}
public class TodaySupervisionItem(int afterPeriod, string description, bool isSubstitution)
{
public int AfterPeriod { get; } = afterPeriod;
public string PeriodLabel { get; } = afterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {afterPeriod}. Stunde";
public string Description { get; } = description;
public bool IsSubstitution { get; } = isSubstitution;
}
public class TodaySpecialAssignmentItem(string periodLabel, string description, string groupLabel)
{
public string PeriodLabel { get; } = periodLabel;
public string Description { get; } = description;
public string GroupLabel { get; } = groupLabel;
public bool HasGroupLabel => !string.IsNullOrWhiteSpace(GroupLabel);
public string DisplayText { get; } =
$"{periodLabel} — {description}" + (string.IsNullOrWhiteSpace(groupLabel) ? "" : $" ({groupLabel})");
}
@@ -127,10 +127,29 @@ public partial class SettingsViewModel : ObservableObject
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
// ── Stundenraster: Uhrzeiten je Einzelstunde (4.2.2 Nachtrag) ────────────
[ObservableProperty] private string _periodTimesError = "";
[ObservableProperty] private string _periodTimesStatus = "";
public ObservableCollection<PeriodTimeEditItem> PeriodTimes { get; } = [];
// ── Aufsichten: wiederkehrende Pausenaufsicht (4.3 Nachtrag) ─────────────
[ObservableProperty] private string _newDutyWeekdayName = WeekdayDisplay.Options[0];
[ObservableProperty] private int _newDutyAfterPeriod;
[ObservableProperty] private string _newDutyLocation = "";
[ObservableProperty] private string _newDutyError = "";
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
public ObservableCollection<SupervisionDutyItem> SupervisionDuties { get; } = [];
// ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly PeriodScheduleService _periodSchedule;
private readonly ISupervisionDutyRepository _supervisionDuties;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
@@ -138,7 +157,8 @@ public partial class SettingsViewModel : ObservableObject
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
IDocumentationRepository documentation, IStudentRepository students,
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings)
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties)
{
_subjects = subjects;
_domainRepo = domainRepo;
@@ -155,6 +175,8 @@ public partial class SettingsViewModel : ObservableObject
_shorthandCodes = shorthandCodes;
_schoolHolidays = schoolHolidays;
_calendarSettings = calendarSettings;
_periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties;
LoadSubjects();
LoadShorthandCodes();
LoadGradingKeyTemplates();
@@ -167,6 +189,84 @@ public partial class SettingsViewModel : ObservableObject
LoadExpiredDocuments();
SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State);
LoadSchoolHolidays();
LoadPeriodTimes();
LoadSupervisionDuties();
}
// ── Aufsichten: Laden / Hinzufügen / Löschen ─────────────────────────────
private void LoadSupervisionDuties()
{
SupervisionDuties.Clear();
foreach (var d in _supervisionDuties.GetAll())
SupervisionDuties.Add(new SupervisionDutyItem(d));
}
[RelayCommand]
private void AddSupervisionDuty()
{
NewDutyError = "";
if (string.IsNullOrWhiteSpace(NewDutyLocation)) { NewDutyError = "Ort/Bezeichnung erforderlich."; return; }
if (NewDutyAfterPeriod is < 0 or > 10) { NewDutyError = "Muss zwischen 0 und 10 liegen."; return; }
try
{
_supervisionDuties.Save(new SupervisionDuty
{
Weekday = WeekdayDisplay.FromLabel(NewDutyWeekdayName),
AfterPeriod = NewDutyAfterPeriod,
Location = NewDutyLocation.Trim(),
});
}
catch (InvalidOperationException ex) { NewDutyError = ex.Message; return; }
NewDutyLocation = ""; NewDutyAfterPeriod = 0;
LoadSupervisionDuties();
}
[RelayCommand]
private void RemoveSupervisionDuty(SupervisionDutyItem? item)
{
if (item is null) return;
_supervisionDuties.Delete(item.Id);
SupervisionDuties.Remove(item);
}
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes()
{
PeriodTimes.Clear();
for (var period = 1; period <= 10; period++)
{
var times = _periodSchedule.GetTimes(period);
PeriodTimes.Add(new PeriodTimeEditItem(period, times?.Start, times?.End));
}
}
[RelayCommand]
private void SavePeriodTimes()
{
PeriodTimesError = ""; PeriodTimesStatus = "";
var entries = new List<PeriodTimeEntry>();
foreach (var item in PeriodTimes)
{
if (string.IsNullOrWhiteSpace(item.StartText) && string.IsNullOrWhiteSpace(item.EndText))
continue; // Stunde bewusst nicht konfiguriert — ok, keine Pflicht für alle 10.
if (!TimeOnly.TryParseExact(item.StartText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
!TimeOnly.TryParseExact(item.EndText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
{ PeriodTimesError = $"{item.PeriodLabel}: Format HH:MM."; return; }
if (end <= start)
{ PeriodTimesError = $"{item.PeriodLabel}: Ende muss nach dem Beginn liegen."; return; }
entries.Add(new PeriodTimeEntry { PeriodNumber = item.PeriodNumber, Start = start, End = end });
}
_periodSchedule.SetPeriods(entries);
PeriodTimesStatus = "Gespeichert.";
}
// ── Ferien & Feiertage: Bundesland / Schulferien pflegen ─────────────────
@@ -842,6 +942,47 @@ public class SchoolHolidayItem(SchoolHoliday h)
public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} {h.EndDate:dd.MM.yyyy}";
}
public partial class PeriodTimeEditItem : ObservableObject
{
public int PeriodNumber { get; }
public string PeriodLabel { get; }
[ObservableProperty] private string _startText;
[ObservableProperty] private string _endText;
public PeriodTimeEditItem(int periodNumber, TimeOnly? start, TimeOnly? end)
{
PeriodNumber = periodNumber;
PeriodLabel = $"{periodNumber}. Stunde";
_startText = start?.ToString("HH:mm") ?? "";
_endText = end?.ToString("HH:mm") ?? "";
}
}
// ── Wochentag: deutsche Anzeige (MoFr, für Aufsichten) ────────────────────────
public static class WeekdayDisplay
{
private static readonly (DayOfWeek Day, string Name)[] Entries =
[
(DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"),
(DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"),
];
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name;
public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day;
}
public class SupervisionDutyItem(SupervisionDuty d)
{
public Guid Id { get; } = d.Id;
public string WeekdayLabel { get; } = WeekdayDisplay.Label(d.Weekday);
public int AfterPeriod { get; } = d.AfterPeriod;
public string PeriodLabel { get; } = d.AfterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {d.AfterPeriod}. Stunde";
public string Location { get; } = d.Location;
}
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
internal class CatalogDto