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
@@ -98,6 +98,19 @@ public interface ISchoolHolidayRepository
void Save(SchoolHoliday holiday);
void Delete(Guid id);
}
public interface ISupervisionDutyRepository
{
List<SupervisionDuty> GetAll();
void Save(SupervisionDuty duty);
void Delete(Guid id);
}
public interface ISubstitutionEntryRepository
{
List<SubstitutionEntry> GetAll();
List<SubstitutionEntry> GetByDate(DateOnly date);
void Save(SubstitutionEntry entry);
void Delete(Guid id);
}
public interface IDocumentationRepository
{
List<Documentation> GetByStudent(Guid studentId);
+59
View File
@@ -143,6 +143,65 @@ public enum GermanState
BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH
}
/// <summary>
/// Wiederkehrende Pausenaufsicht (kein Bezug zu einer Lerngruppe, anders als
/// <see cref="TimetableSlot"/>). <see cref="AfterPeriod"/> = 0 bedeutet "vor der 1. Stunde"
/// (Frühaufsicht), sonst "in der Pause nach dieser Stunde". Pro Wochentag/Pause höchstens eine
/// Aufsicht (ein Lehrer kann nicht an zwei Orten gleichzeitig Aufsicht führen).
/// </summary>
public class SupervisionDuty
{
public Guid Id { get; set; } = Guid.NewGuid();
public DayOfWeek Weekday { get; set; }
public int AfterPeriod { get; set; }
public string Location { get; set; } = "";
}
public enum SubstitutionKind { Supervision, Lesson, SpecialAssignment, Cancelled }
/// <summary>
/// Einmalige Ausnahme vom regulären Plan an einem konkreten Datum: eine Vertretungsaufsicht
/// (zusätzlich zur oder anstelle einer regulären <see cref="SupervisionDuty"/>), eine
/// Vertretungsstunde in einer eigenen oder fremden Lerngruppe, ein Sondereinsatz (Ausflug,
/// Berufsmesse, Exkursion, ...), der einen Teil des Tages oder den ganzen Tag blockiert, oder ein
/// schlichter Stundenausfall ohne Ersatz (z.B. weil die betroffene Gruppe selbst auf Klassenfahrt
/// ist). Bewusst getrennt von <see cref="Lesson"/>: die meisten dieser Einträge sind nicht Teil der
/// durchgeplanten Einheiten-Reihenfolge (siehe TODO.md, Nachtrag zu 4.3) — nur bei
/// <see cref="SubstitutionKind.Lesson"/> kann beim Anlegen ausdrücklich "als Stunde in der Einheit
/// übernehmen" gewählt werden, was zusätzlich eine echte <see cref="Lesson"/> erzeugt; dieser
/// Eintrag hier bleibt trotzdem bestehen, da er (unabhängig von der Einheiten-Fortschrittsanzeige)
/// das, was tatsächlich im Plan an diesem Tag stattfand, festhält.
/// </summary>
public class SubstitutionEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateOnly Date { get; set; }
public SubstitutionKind Kind { get; set; }
/// Bei <see cref="SubstitutionKind.Lesson"/> und <see cref="SubstitutionKind.Cancelled"/>
/// gesetzt — bei Cancelled wird Gruppe/Fach zur Anzeige aus dem an dieser Stelle regulär
/// eingetragenen <see cref="TimetableSlot"/> abgeleitet, nicht hier gespeichert.
public int? PeriodNumber { get; set; }
/// Nur bei <see cref="SubstitutionKind.Supervision"/> gesetzt — wie bei
/// <see cref="SupervisionDuty.AfterPeriod"/>, 0 = vor der 1. Stunde.
public int? AfterPeriod { get; set; }
/// Nur bei <see cref="SubstitutionKind.SpecialAssignment"/> und nicht <see cref="IsAllDay"/>:
/// der belegte Stundenbereich (inklusive).
public int? FromPeriod { get; set; }
public int? ToPeriod { get; set; }
/// Nur bei <see cref="SubstitutionKind.SpecialAssignment"/>: blockiert den ganzen Tag statt
/// nur <see cref="FromPeriod"/><see cref="ToPeriod"/>.
public bool IsAllDay { get; set; }
/// Gesetzt, wenn es sich um eine eigene Lerngruppe handelt — bei fremden/unbekannten Gruppen
/// oder Sondereinsätzen ohne Gruppenbezug (z.B. Berufsmesse) bleibt es null und nur
/// <see cref="GroupLabel"/> beschreibt ggf., um welche Gruppe es ging.
public Guid? GroupId { get; set; }
public string GroupLabel { get; set; } = "";
/// Thema (bei Lesson), Grund/Ort (bei Supervision, z.B. "Vertretung für Hr. Müller") bzw.
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
public string Description { get; set; } = "";
public string? Notes { get; set; }
}
/// <summary>
/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr).
/// <see cref="CalculatedValue"/> ist das zuletzt berechnete Ergebnis; <see cref="OverrideValue"/>
@@ -0,0 +1,62 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
public class PeriodTimeEntry
{
public int PeriodNumber { get; set; }
public TimeOnly Start { get; set; }
public TimeOnly End { get; set; }
}
internal class PeriodScheduleConfig
{
public List<PeriodTimeEntry> Periods { get; set; } = [];
}
/// <summary>
/// Uhrzeiten der Einzelstunden (Stundenraster) — Grundlage für die Zeitbedarf-Rückmeldung im
/// Verlaufsplan-Editor (4.2.2 Nachtrag). Nicht jede Schule/Stunde muss konfiguriert sein: fehlt
/// eine Stunde, liefert <see cref="GetDurationMinutes"/> 0 statt eines Standardwerts — die
/// Zeitbedarf-Anzeige blendet sich dann einfach aus, statt eine erfundene Dauer vorzutäuschen.
/// </summary>
public class PeriodScheduleService
{
private readonly string _configPath;
private PeriodScheduleConfig _config;
public IReadOnlyList<PeriodTimeEntry> Periods => _config.Periods;
public PeriodScheduleService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "periodschedule.json");
_config = Load();
}
public void SetPeriods(List<PeriodTimeEntry> periods)
{
_config.Periods = periods;
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
public (TimeOnly Start, TimeOnly End)? GetTimes(int periodNumber)
{
var entry = _config.Periods.FirstOrDefault(p => p.PeriodNumber == periodNumber);
return entry is null ? null : (entry.Start, entry.End);
}
public int GetDurationMinutes(int periodNumber) =>
GetTimes(periodNumber) is { } t ? (int)(t.End - t.Start).TotalMinutes : 0;
private PeriodScheduleConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<PeriodScheduleConfig>(File.ReadAllText(_configPath))
?? new PeriodScheduleConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new PeriodScheduleConfig();
}
}