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
|
||||
|
||||
@@ -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 (Mo–Fr, 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
|
||||
|
||||
Reference in New Issue
Block a user