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();
}
}
+57
View File
@@ -588,4 +588,61 @@ public sealed class RepositoryTests
Assert.Equal("Osterferien", result[0].Name);
Assert.Equal("Sommerferien", result[1].Name);
}
// ── SupervisionDutyRepository ─────────────────────────────────────────────
[Fact]
public void SupervisionDutyRepository_Save_LehntDoppelbelegungDerselbenPauseAb()
{
using var db = NewInMemoryContext();
var repo = new SupervisionDutyRepository(db);
repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" });
Assert.Throws<InvalidOperationException>(() =>
repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Bibliothek" }));
}
[Fact]
public void SupervisionDutyRepository_Save_AktualisierenDerselbenAufsichtIstErlaubt()
{
using var db = NewInMemoryContext();
var repo = new SupervisionDutyRepository(db);
var duty = new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" };
repo.Save(duty);
duty.Location = "Bibliothek";
repo.Save(duty);
Assert.Equal("Bibliothek", repo.GetAll().Single().Location);
}
[Fact]
public void SupervisionDutyRepository_GetAll_SortiertNachWochentagUndPause()
{
using var db = NewInMemoryContext();
var repo = new SupervisionDutyRepository(db);
repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Tuesday, AfterPeriod = 1, Location = "A" });
repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 4, Location = "B" });
repo.Save(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "C" });
var result = repo.GetAll();
Assert.Equal(["C", "B", "A"], result.Select(d => d.Location));
}
// ── SubstitutionEntryRepository ───────────────────────────────────────────
[Fact]
public void SubstitutionEntryRepository_GetByDate_FindetNurEintraegeDesTages()
{
using var db = NewInMemoryContext();
var repo = new SubstitutionEntryRepository(db);
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Lesson, PeriodNumber = 3, Description = "Vertretung 8a" });
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Supervision, AfterPeriod = 2, Description = "Vertretung Hr. Müller" });
var result = repo.GetByDate(new DateOnly(2026, 3, 12));
Assert.Single(result);
Assert.Equal("Vertretung 8a", result[0].Description);
}
}
+5
View File
@@ -59,6 +59,8 @@ public class LiteDbContext : IDisposable
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public void Checkpoint() => _db.Checkpoint();
@@ -374,6 +376,9 @@ public class LiteDbContext : IDisposable
TimetableSlots.EnsureIndex(x => x.GroupId);
TimetableSlots.EnsureIndex("ux_timetable_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true);
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
SubstitutionEntries.EnsureIndex(x => x.Date);
}
public void Dispose() => _db.Dispose();
@@ -410,6 +410,30 @@ public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepositor
public void Delete(Guid id) => db.SchoolHolidays.Delete(id);
}
public class SupervisionDutyRepository(LiteDbContext db) : ISupervisionDutyRepository
{
public List<SupervisionDuty> GetAll() =>
db.SupervisionDuties.FindAll().OrderBy(d => d.Weekday).ThenBy(d => d.AfterPeriod).ToList();
public void Save(SupervisionDuty duty)
{
var occupied = db.SupervisionDuties.FindAll()
.FirstOrDefault(d => d.Weekday == duty.Weekday && d.AfterPeriod == duty.AfterPeriod);
if (occupied is not null && occupied.Id != duty.Id)
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
db.SupervisionDuties.Upsert(duty);
}
public void Delete(Guid id) => db.SupervisionDuties.Delete(id);
}
public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryRepository
{
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public void Save(SubstitutionEntry entry) => db.SubstitutionEntries.Upsert(entry);
public void Delete(Guid id) => db.SubstitutionEntries.Delete(id);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+26
View File
@@ -243,6 +243,32 @@ public class FakeSchoolHolidays : ISchoolHolidayRepository
public void Delete(Guid id) => _all.RemoveAll(h => h.Id == id);
}
public class FakeSupervisionDuties : ISupervisionDutyRepository
{
private readonly List<SupervisionDuty> _all = [];
public void Add(SupervisionDuty d) => _all.Add(d);
public List<SupervisionDuty> GetAll() => _all.ToList();
public void Save(SupervisionDuty duty)
{
var occupied = _all.FirstOrDefault(d => d.Weekday == duty.Weekday && d.AfterPeriod == duty.AfterPeriod);
if (occupied is not null && occupied.Id != duty.Id)
throw new InvalidOperationException("Für diese Pause ist bereits eine Aufsicht eingetragen.");
_all.RemoveAll(d => d.Id == duty.Id);
_all.Add(duty);
}
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
}
public class FakeSubstitutionEntries : ISubstitutionEntryRepository
{
private readonly List<SubstitutionEntry> _all = [];
public void Add(SubstitutionEntry e) => _all.Add(e);
public List<SubstitutionEntry> GetAll() => _all.ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeReportGrades : IReportGradeRepository
{
private readonly List<ReportGrade> _all = [];
@@ -0,0 +1,142 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für die Serienerzeugung von Stunden aus dem Stundenplan (4.2.5).
public sealed class GenerateLessonSeriesDialogViewModelTests
{
private static GenerateLessonSeriesDialogViewModel BuildVm(FakeTimetableSlots slots, FakeLessons lessons,
FakeSchoolHolidays holidays, Guid unitId, Guid groupId, DateOnly? from = null, DateOnly? to = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf.
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-lessonseries-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var vm = new GenerateLessonSeriesDialogViewModel(slots, lessons, holidays,
new PublicHolidayService(), new SchoolCalendarSettingsService(tempPath), unitId, groupId, null, null);
if (from is not null) vm.FromDateText = from.Value.ToString("dd.MM.yyyy");
if (to is not null) vm.ToDateText = to.Value.ToString("dd.MM.yyyy");
return vm;
}
[Fact]
public void Save_ErzeugtStundeProVorkommenDesWochentagsImZeitraum()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var to = from.AddDays(13); // deckt zwei Vorkommen des Wochentags von "from" ab (Tag 0 und 7)
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, to);
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(2, vm.Result!.Created);
Assert.Equal(0, vm.Result.SkippedHoliday);
Assert.Equal(0, vm.Result.SkippedExisting);
Assert.All(lessons.GetByGroupAndRange(groupId, from, to), l => Assert.Equal(unitId, l.UnitId));
}
[Fact]
public void Save_UeberspringtTerminInSchulferien()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var to = from.AddDays(13);
var secondOccurrence = from.AddDays(7);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 });
var holidays = new FakeSchoolHolidays();
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = secondOccurrence, EndDate = secondOccurrence.AddDays(3) });
var vm = BuildVm(slots, new FakeLessons(), holidays, unitId, groupId, from, to);
vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
Assert.Equal(1, vm.Result.SkippedHoliday);
}
[Fact]
public void Save_UeberspringtBereitsVorhandeneStundeAmSelbenDatumUndPeriode()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var to = from.AddDays(13);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 });
var lessons = new FakeLessons();
lessons.Add(new Lesson { UnitId = Guid.NewGuid(), GroupId = groupId, Date = from, LessonNumber = 1, Topic = "Bestehend" });
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, to);
vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
Assert.Equal(1, vm.Result.SkippedExisting);
Assert.Equal(2, lessons.GetByGroupAndRange(groupId, from, to).Count); // 1 alt + 1 neu
}
[Fact]
public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 4 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
vm.SaveCommand.Execute(null);
Assert.Equal(2, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList();
Assert.Equal([3, 4], created);
}
[Fact]
public void Save_OhneStundenplanEintrag_SetztFehlerUndErzeugtNichts()
{
var groupId = Guid.NewGuid();
var vm = BuildVm(new FakeTimetableSlots(), new FakeLessons(), new FakeSchoolHolidays(), Guid.NewGuid(), groupId,
new DateOnly(2025, 9, 1), new DateOnly(2025, 9, 14));
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.DateError);
}
[Fact]
public void Save_UngueltigesDatumsformat_SetztFehler()
{
var vm = BuildVm(new FakeTimetableSlots(), new FakeLessons(), new FakeSchoolHolidays(), Guid.NewGuid(), Guid.NewGuid());
vm.FromDateText = "keinDatum";
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.DateError);
}
[Fact]
public void Save_EndeVorBeginn_SetztFehler()
{
var vm = BuildVm(new FakeTimetableSlots(), new FakeLessons(), new FakeSchoolHolidays(), Guid.NewGuid(), Guid.NewGuid(),
new DateOnly(2025, 9, 10), new DateOnly(2025, 9, 1));
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.DateError);
}
}
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
@@ -6,8 +7,18 @@ namespace LehrerApp.Desktop.Tests;
public sealed class LessonDialogViewModelTests
{
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null) =>
private static PeriodScheduleService NewPeriodSchedule()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-lessondialogvm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new PeriodScheduleService(tempPath);
}
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) =>
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
unitId, groupId, [], [], editing);
[Fact]
@@ -71,6 +82,7 @@ public sealed class LessonDialogViewModelTests
{
var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]),
new FakeTimetableSlots(), NewPeriodSchedule(),
Guid.NewGuid(), Guid.NewGuid(), [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen
Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
@@ -133,7 +145,7 @@ public sealed class LessonDialogViewModelTests
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
unitId, groupId, [], [], null)
new FakeTimetableSlots(), NewPeriodSchedule(), unitId, groupId, [], [], null)
{
Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45",
};
@@ -230,9 +242,140 @@ public sealed class LessonDialogViewModelTests
};
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths,
Guid.NewGuid(), Guid.NewGuid(), [], [], editing);
new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), [], [], editing);
Assert.True(vm.Phases[0].HasAlternativePath);
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName);
}
// ── Zeitbedarf-Rückmeldung (4.2.2 Nachtrag: Stundenraster) ───────────────────
[Fact]
public void RecomputeTimeBudget_EinzelneStunde_ZeigtVerfuegbareZeitAusStundenraster()
{
var groupId = Guid.NewGuid();
var date = new DateOnly(2025, 9, 1);
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) }]);
var vm = BuildVm(Guid.NewGuid(), groupId, periodSchedule: periodSchedule);
vm.DateText = date.ToString("dd.MM.yyyy");
vm.LessonNumber = 3;
vm.AddPhaseCommand.Execute(null);
vm.Phases[0].DurationMinutes = 40;
Assert.True(vm.HasTimeBudgetInfo);
Assert.Contains("40 von 45 Minuten geplant", vm.TimeBudgetLabel);
}
[Fact]
public void RecomputeTimeBudget_Doppelstunde_AddiertBeidePerioden()
{
var groupId = Guid.NewGuid();
var date = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 4 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) },
new PeriodTimeEntry { PeriodNumber = 4, Start = new TimeOnly(10, 30), End = new TimeOnly(11, 15) },
]);
var vm = BuildVm(Guid.NewGuid(), groupId, slots: slots, periodSchedule: periodSchedule);
vm.DateText = date.ToString("dd.MM.yyyy");
vm.LessonNumber = 3;
vm.AddPhaseCommand.Execute(null);
vm.Phases[0].DurationMinutes = 84;
Assert.True(vm.HasTimeBudgetInfo);
Assert.Contains("84 von 90 Minuten geplant", vm.TimeBudgetLabel);
}
[Fact]
public void RecomputeTimeBudget_FolgeperiodeGehoertAndererGruppe_WirdNichtMitgezaehlt()
{
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var date = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = otherGroupId, Weekday = date.DayOfWeek, PeriodNumber = 4 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) },
new PeriodTimeEntry { PeriodNumber = 4, Start = new TimeOnly(10, 30), End = new TimeOnly(11, 15) },
]);
var vm = BuildVm(Guid.NewGuid(), groupId, slots: slots, periodSchedule: periodSchedule);
vm.DateText = date.ToString("dd.MM.yyyy");
vm.LessonNumber = 3;
vm.AddPhaseCommand.Execute(null);
vm.Phases[0].DurationMinutes = 40;
Assert.Contains("40 von 45 Minuten geplant", vm.TimeBudgetLabel);
}
[Fact]
public void RecomputeTimeBudget_FarbeGruenImZielbereich()
{
var date = new DateOnly(2025, 9, 1);
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule);
vm.DateText = date.ToString("dd.MM.yyyy");
vm.LessonNumber = 1;
vm.AddPhaseCommand.Execute(null);
vm.Phases[0].DurationMinutes = 42; // 42/45 ≈ 93 % — Zielbereich
Assert.Equal("#43A047", vm.TimeBudgetColorHex);
}
[Fact]
public void RecomputeTimeBudget_FarbeDunkelrotBeiDeutlicherUeberplanung()
{
var date = new DateOnly(2025, 9, 1);
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule);
vm.DateText = date.ToString("dd.MM.yyyy");
vm.LessonNumber = 1;
vm.AddPhaseCommand.Execute(null);
vm.Phases[0].DurationMinutes = 60; // 60/45 ≈ 133 % — deutlich überplant
Assert.Equal("#B71C1C", vm.TimeBudgetColorHex);
}
[Fact]
public void LessonNumberGeaendert_UebernimmtBeginnAusStundenraster_WennNochKeinerEingetragen()
{
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 5, Start = new TimeOnly(11, 45), End = new TimeOnly(12, 30) }]);
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule);
vm.LessonNumber = 5;
Assert.Equal("11:45", vm.StartTimeText);
}
[Fact]
public void LessonNumberGeaendert_UeberschreibtBereitsEingetragenenBeginnNicht()
{
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 5, Start = new TimeOnly(11, 45), End = new TimeOnly(12, 30) }]);
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule);
vm.StartTimeText = "09:00";
vm.LessonNumber = 5;
Assert.Equal("09:00", vm.StartTimeText);
}
[Fact]
public void RecomputeTimeBudget_OhneStundenrasterEintrag_KeineRueckmeldung()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // leeres PeriodScheduleService
vm.DateText = "01.09.2025";
vm.LessonNumber = 1;
vm.AddPhaseCommand.Execute(null);
Assert.False(vm.HasTimeBudgetInfo);
}
}
@@ -181,6 +181,29 @@ public class PlanningTabViewModelTests
Assert.Equal(["Arbeitsblatt", "Modell"], vm.KnownMaterials);
}
[Fact]
public async Task GenerateLessonSeries_RuftDelegateMitAusgewaehlterEinheitAufUndLaedtBeiErfolgNeu()
{
var (vm, units, lessons, groupId) = BuildScenario();
var unit = new Unit { GroupId = groupId, Title = "Optik" };
units.Add(unit);
vm.Initialize(groupId);
vm.SelectedUnit = vm.Units.Single(u => u.Id == unit.Id);
Unit? passedUnit = null;
vm.OnGenerateLessonSeries = u =>
{
passedUnit = u;
lessons.Add(new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "" });
return Task.FromResult<LessonSeriesResult?>(new LessonSeriesResult(1, 0, 0));
};
await vm.GenerateLessonSeriesCommand.ExecuteAsync(null);
Assert.Equal(unit.Id, passedUnit!.Id);
Assert.Single(vm.Lessons);
}
[Fact]
public void LoadUnits_SammeltKurzsymboleAusAllenStundenphasenDerGruppe()
{
@@ -8,7 +8,8 @@ namespace LehrerApp.Desktop.Tests;
public sealed class SettingsViewModelTests
{
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null)
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -22,7 +23,8 @@ public sealed class SettingsViewModelTests
new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath));
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties());
}
[Fact]
@@ -97,10 +99,123 @@ public sealed class SettingsViewModelTests
new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings);
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties());
vm.SelectedStateName = "Bayern";
Assert.Equal(GermanState.BY, calendarSettings.State);
}
[Fact]
public void SavePeriodTimes_GueltigeEingabe_WirdPersistiert()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-periods-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var periodSchedule = new PeriodScheduleService(tempPath);
var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties());
vm.PeriodTimes[0].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45";
vm.SavePeriodTimesCommand.Execute(null);
Assert.Equal("", vm.PeriodTimesError);
Assert.Equal(45, periodSchedule.GetDurationMinutes(1));
}
[Fact]
public void SavePeriodTimes_EndeVorBeginn_SetztFehlerUndSpeichertNicht()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-periods-invalid-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var periodSchedule = new PeriodScheduleService(tempPath);
var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties());
vm.PeriodTimes[0].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00";
vm.SavePeriodTimesCommand.Execute(null);
Assert.NotEqual("", vm.PeriodTimesError);
Assert.Equal(0, periodSchedule.GetDurationMinutes(1));
}
[Fact]
public void AddSupervisionDuty_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
{
var duties = new FakeSupervisionDuties();
var vm = BuildViewModel(supervisionDuties: duties);
vm.NewDutyWeekdayName = "Montag";
vm.NewDutyAfterPeriod = 2;
vm.NewDutyLocation = "Pausenhof";
vm.AddSupervisionDutyCommand.Execute(null);
Assert.Single(duties.GetAll());
Assert.Single(vm.SupervisionDuties);
Assert.Equal(DayOfWeek.Monday, duties.GetAll()[0].Weekday);
Assert.Equal(2, duties.GetAll()[0].AfterPeriod);
}
[Fact]
public void AddSupervisionDuty_FehlenderOrt_SetztFehlerUndSpeichertNicht()
{
var duties = new FakeSupervisionDuties();
var vm = BuildViewModel(supervisionDuties: duties);
vm.NewDutyWeekdayName = "Montag";
vm.NewDutyAfterPeriod = 2;
vm.AddSupervisionDutyCommand.Execute(null);
Assert.Empty(duties.GetAll());
Assert.NotEqual("", vm.NewDutyError);
}
[Fact]
public void AddSupervisionDuty_PauseBereitsBelegt_ZeigtFreundlicheFehlermeldung()
{
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" });
var vm = BuildViewModel(supervisionDuties: duties);
vm.NewDutyWeekdayName = "Montag";
vm.NewDutyAfterPeriod = 2;
vm.NewDutyLocation = "Bibliothek";
vm.AddSupervisionDutyCommand.Execute(null);
Assert.Single(duties.GetAll());
Assert.Equal("Für diese Pause ist bereits eine Aufsicht eingetragen.", vm.NewDutyError);
}
[Fact]
public void RemoveSupervisionDuty_EntferntEintragAusRepositoryUndListe()
{
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" });
var vm = BuildViewModel(supervisionDuties: duties);
vm.RemoveSupervisionDutyCommand.Execute(vm.SupervisionDuties[0]);
Assert.Empty(duties.GetAll());
Assert.Empty(vm.SupervisionDuties);
}
}
@@ -0,0 +1,323 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SubstitutionEntryDialogViewModelTests
{
private static SubstitutionEntryDialogViewModel BuildVm(
FakeSubstitutionEntries? substitutions = null, FakeGroups? groups = null,
FakeUnits? units = null, FakeLessons? lessons = null) =>
new(substitutions ?? new FakeSubstitutionEntries(), groups ?? new FakeGroups([]),
units ?? new FakeUnits(), lessons ?? new FakeLessons());
[Fact]
public void Save_Aufsicht_GueltigeEingabe_ErzeugtSupervisionEntry()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Aufsicht";
vm.AfterPeriod = 2;
vm.Description = "Vertretung für Hr. Müller";
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
var saved = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, saved.Kind);
Assert.Equal(2, saved.AfterPeriod);
Assert.Equal("Vertretung für Hr. Müller", saved.Description);
}
[Fact]
public void Save_Aufsicht_FehlenderGrund_SetztFehlerUndSpeichertNicht()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Aufsicht";
vm.AfterPeriod = 2;
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Empty(substitutions.GetAll());
Assert.NotEqual("", vm.ValidationError);
}
[Fact]
public void Save_Stunde_FremdeGruppe_ErzeugtEintragOhneGroupId()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Stunde";
vm.PeriodNumber = 3;
vm.GroupLabel = "8a";
vm.Description = "Vertretung Erdkunde";
vm.SaveCommand.Execute(null);
var saved = Assert.Single(substitutions.GetAll());
Assert.Null(saved.GroupId);
Assert.Equal("8a", saved.GroupLabel);
Assert.Equal(SubstitutionKind.Lesson, saved.Kind);
}
[Fact]
public void Save_Stunde_EigeneGruppeOhnePromote_ErzeugtKeineLesson()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var unit = new Unit { GroupId = group.Id, Title = "Optik" };
var units = new FakeUnits(); units.Add(unit);
var lessons = new FakeLessons();
var vm = BuildVm(groups: new FakeGroups([group]), units: units, lessons: lessons);
vm.DateText = "12.03.2026";
vm.KindName = "Stunde";
vm.PeriodNumber = 3;
vm.SelectedOwnGroup = group;
vm.Description = "Stillarbeit";
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Empty(lessons.GetByUnit(unit.Id));
}
[Fact]
public void Save_Stunde_EigeneGruppeMitPromote_ErzeugtZusaetzlichLesson()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var unit = new Unit { GroupId = group.Id, Title = "Optik" };
var units = new FakeUnits(); units.Add(unit);
var lessons = new FakeLessons();
var vm = BuildVm(groups: new FakeGroups([group]), units: units, lessons: lessons);
vm.DateText = "12.03.2026";
vm.KindName = "Stunde";
vm.PeriodNumber = 3;
vm.SelectedOwnGroup = group; // füllt UnitsOfSelectedGroup + SelectedUnit
vm.Description = "Brechung (vorgezogen)";
vm.PromoteToLesson = true;
vm.SaveCommand.Execute(null);
var lesson = Assert.Single(lessons.GetByUnit(unit.Id));
Assert.Equal(group.Id, lesson.GroupId);
Assert.Equal(3, lesson.LessonNumber);
Assert.Equal("Brechung (vorgezogen)", lesson.Topic);
Assert.Equal(new DateOnly(2026, 3, 12), lesson.Date);
}
[Fact]
public void SelectedOwnGroupChanged_FuelltGroupLabelUndEinheitenListe()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var unit = new Unit { GroupId = group.Id, Title = "Optik" };
var units = new FakeUnits(); units.Add(unit);
var vm = BuildVm(groups: new FakeGroups([group]), units: units);
vm.SelectedOwnGroup = group;
Assert.Equal("Q1 Chemie", vm.GroupLabel);
Assert.Single(vm.UnitsOfSelectedGroup);
Assert.Equal(unit.Id, vm.SelectedUnit!.Id);
}
[Fact]
public void CanPromoteToLesson_FalseWennGruppeKeineEinheitenHat()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var vm = BuildVm(groups: new FakeGroups([group]));
vm.KindName = "Stunde";
vm.SelectedOwnGroup = group;
Assert.False(vm.CanPromoteToLesson);
}
[Fact]
public void Save_UngueltigesDatum_SetztFehler()
{
var vm = BuildVm();
vm.DateText = "keinDatum";
vm.KindName = "Aufsicht";
vm.Description = "x";
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.DateError);
}
// ── Sondereinsatz ─────────────────────────────────────────────────────────
[Fact]
public void Save_Sondereinsatz_Ganztaegig_ErzeugtEintragOhneStundenbereich()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Sondereinsatz";
vm.IsAllDay = true;
vm.Description = "Ausflug ins Museum";
vm.SaveCommand.Execute(null);
var saved = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.SpecialAssignment, saved.Kind);
Assert.True(saved.IsAllDay);
Assert.Null(saved.FromPeriod);
Assert.Null(saved.ToPeriod);
}
[Fact]
public void Save_Sondereinsatz_MitStundenbereich_SpeichertVonBis()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Sondereinsatz";
vm.IsAllDay = false;
vm.FromPeriod = 3;
vm.ToPeriod = 4;
vm.Description = "Berufsmesse";
vm.SaveCommand.Execute(null);
var saved = Assert.Single(substitutions.GetAll());
Assert.False(saved.IsAllDay);
Assert.Equal(3, saved.FromPeriod);
Assert.Equal(4, saved.ToPeriod);
}
[Fact]
public void Save_Sondereinsatz_BisVorVon_SetztFehler()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Sondereinsatz";
vm.IsAllDay = false;
vm.FromPeriod = 5;
vm.ToPeriod = 2;
vm.Description = "Berufsmesse";
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Empty(substitutions.GetAll());
Assert.NotEqual("", vm.ValidationError);
}
[Fact]
public void Save_Sondereinsatz_OhneBeschreibung_SetztFehler()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Sondereinsatz";
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Empty(substitutions.GetAll());
Assert.NotEqual("", vm.ValidationError);
}
[Fact]
public void Save_Sondereinsatz_OhneGruppe_SpeichertLeeresGroupLabel()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Sondereinsatz";
vm.Description = "Berufsmesse";
vm.SaveCommand.Execute(null);
var saved = Assert.Single(substitutions.GetAll());
Assert.Null(saved.GroupId);
Assert.Equal("", saved.GroupLabel);
}
[Fact]
public void CanPromoteToLesson_FalseFuerSondereinsatz()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var unit = new Unit { GroupId = group.Id, Title = "Optik" };
var units = new FakeUnits(); units.Add(unit);
var vm = BuildVm(groups: new FakeGroups([group]), units: units);
vm.KindName = "Sondereinsatz";
vm.SelectedOwnGroup = group;
Assert.False(vm.CanPromoteToLesson);
}
// ── Ausfall ───────────────────────────────────────────────────────────────
[Fact]
public void Save_Ausfall_GueltigeEingabe_ErzeugtEintragMitPeriodeUndGrund()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Ausfall";
vm.PeriodNumber = 2;
vm.Description = "6a auf Klassenfahrt";
vm.SaveCommand.Execute(null);
var saved = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Cancelled, saved.Kind);
Assert.Equal(2, saved.PeriodNumber);
Assert.Equal("6a auf Klassenfahrt", saved.Description);
}
[Fact]
public void Save_Ausfall_OhneGrund_IstErlaubt()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Ausfall";
vm.PeriodNumber = 2;
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Single(substitutions.GetAll());
}
[Fact]
public void Save_Ausfall_UngueltigePeriode_SetztFehler()
{
var substitutions = new FakeSubstitutionEntries();
var vm = BuildVm(substitutions);
vm.DateText = "12.03.2026";
vm.KindName = "Ausfall";
vm.PeriodNumber = 11;
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Empty(substitutions.GetAll());
Assert.NotEqual("", vm.ValidationError);
}
[Fact]
public void CanPromoteToLesson_FalseFuerAusfall()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var unit = new Unit { GroupId = group.Id, Title = "Optik" };
var units = new FakeUnits(); units.Add(unit);
var vm = BuildVm(groups: new FakeGroups([group]), units: units);
vm.KindName = "Ausfall";
vm.SelectedOwnGroup = group;
Assert.False(vm.CanPromoteToLesson);
}
}
@@ -10,7 +10,8 @@ public sealed class TimetableViewModelTests
private static TimetableViewModel BuildViewModel(
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null)
SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -22,7 +23,8 @@ public sealed class TimetableViewModelTests
lessons ?? new FakeLessons(), exams ?? new FakeExams([]),
holidays ?? new FakeSchoolHolidays(),
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService());
new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries());
}
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
@@ -410,4 +412,334 @@ public sealed class TimetableViewModelTests
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
Assert.False(cell.HasBadge);
}
// ── Aufsicht: wiederkehrend (Bearbeiten-Raster) ──────────────────────────
[Fact]
public void Load_AufsichtEingetragen_ZeigtSupervisionRowMitOrt()
{
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" });
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), supervisionDuties: duties);
var label = vm.Cells.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 2."));
Assert.NotNull(label);
var mondayCell = vm.Cells
.SkipWhile(c => c != label).Skip(1) // erste Zelle nach dem Label = Montag
.First();
Assert.True(mondayCell.IsSupervisionCell);
Assert.Equal("Pausenhof", mondayCell.SupervisionLocation);
}
[Fact]
public void Load_KeineAufsichtEingetragen_KeineSupervisionRow()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
Assert.DoesNotContain(vm.Cells, c => c.IsSupervisionRow);
}
// ── Aufsicht + Vertretung im Wochenraster ────────────────────────────────
[Fact]
public void Load_Wochenraster_ZeigtWiederkehrendeAufsicht()
{
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" });
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), supervisionDuties: duties);
var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 2."));
var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First();
Assert.Equal("Pausenhof", mondayCell.SupervisionLocation);
Assert.False(mondayCell.IsSubstitutionSupervision);
}
[Fact]
public void Load_Wochenraster_VertretungsaufsichtUeberschreibtRegulaereAnzeige()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = 2,
Description = "Vertretung für Hr. Müller",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]),
supervisionDuties: duties, substitutions: substitutions);
var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 2."));
var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First();
Assert.Equal("Vertretung für Hr. Müller", mondayCell.SupervisionLocation);
Assert.True(mondayCell.IsSubstitutionSupervision);
}
[Fact]
public void Load_Wochenraster_VertretungsaufsichtOhneRegulaereAufsicht_ZeigtEigeneRow()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = 3,
Description = "Vertretung für Fr. Schmidt",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 3."));
var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First();
Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation);
}
[Fact]
public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.Lesson, PeriodNumber = 1,
GroupLabel = "8a", Description = "Vertretung Erdkunde",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.True(cell.IsSubstitutionLesson);
Assert.Equal("8a", cell.GroupName);
Assert.Equal("Vertretung Erdkunde", cell.Topic);
}
[Fact]
public void Load_Wochenraster_VertretungsstundeOhnePassendenSlot_ErscheintTrotzdem()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.Lesson, PeriodNumber = 5,
GroupLabel = "8a", Description = "Vertretung Erdkunde",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 5);
Assert.True(cell.IsSubstitutionLesson);
Assert.True(cell.IsAssigned);
}
// ── Aufsicht + Vertretung in der Tagesliste ("Heute") ────────────────────
[Fact]
public void Load_Heute_ZeigtHeutigeAufsicht()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = today.DayOfWeek, AfterPeriod = 2, Location = "Pausenhof" });
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), supervisionDuties: duties);
var item = Assert.Single(vm.TodaySupervisions);
Assert.Equal("Pausenhof", item.Description);
Assert.False(item.IsSubstitution);
}
[Fact]
public void Load_Heute_VertretungsstundeErsetztNormaleStundenanzeige()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = today, Kind = SubstitutionKind.Lesson, PeriodNumber = 1,
GroupLabel = "8a", Description = "Vertretung Erdkunde",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions);
var item = Assert.Single(vm.TodayItems);
Assert.True(item.IsSubstitution);
Assert.Equal("8a", item.GroupName);
Assert.Equal("Vertretung Erdkunde", item.LessonTopic);
}
[Fact]
public void OpenGroup_MitLeererGroupId_RuftDelegateNichtAuf()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
var called = false;
vm.OnNavigateToGroup = _ => called = true;
vm.OpenGroupCommand.Execute(Guid.Empty);
Assert.False(called);
}
// ── Sondereinsätze (Ausflüge, Berufsmessen, ...) ─────────────────────────
[Fact]
public void Load_Wochenraster_GanztaegigerSondereinsatz_UeberdecktAllePeriodenDesTages()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = true,
GroupLabel = "8a", Description = "Ausflug ins Museum",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
var period1 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
var period7 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 7);
Assert.True(period1.IsSpecialAssignment);
Assert.True(period7.IsSpecialAssignment);
Assert.Equal("Ausflug ins Museum", period1.Topic);
Assert.Equal("8a", period1.GroupName);
}
[Fact]
public void Load_Wochenraster_SondereinsatzMitStundenbereich_NurDortSichtbar()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = false,
FromPeriod = 3, ToPeriod = 4, Description = "Berufsmesse",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
var period2 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 2);
var period3 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 3);
var period4 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 4);
var period5 = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 5);
Assert.False(period2.IsSpecialAssignment);
Assert.True(period3.IsSpecialAssignment);
Assert.True(period4.IsSpecialAssignment);
Assert.False(period5.IsSpecialAssignment);
}
[Fact]
public void Load_Wochenraster_SondereinsatzOhneGruppe_ZeigtLeeresGroupName()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = true, Description = "Berufsmesse",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.Equal("", cell.GroupName);
}
[Fact]
public void Load_Heute_ZeigtGanztaegigenSondereinsatz()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = today, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = true,
GroupLabel = "8a", Description = "Ausflug ins Museum",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
var item = Assert.Single(vm.TodaySpecialAssignments);
Assert.Equal("Ganztägig", item.PeriodLabel);
Assert.Equal("Ausflug ins Museum", item.Description);
Assert.Equal("8a", item.GroupLabel);
}
[Fact]
public void Load_Heute_ZeigtSondereinsatzMitStundenbereich()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = today, Kind = SubstitutionKind.SpecialAssignment, IsAllDay = false,
FromPeriod = 3, ToPeriod = 4, Description = "Berufsmesse",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
var item = Assert.Single(vm.TodaySpecialAssignments);
Assert.Equal("3.4. Stunde", item.PeriodLabel);
}
// ── Stundenausfall ────────────────────────────────────────────────────────
[Fact]
public void Load_Wochenraster_Ausfall_ZeigtGruppeUndFachAusDemStundenplan()
{
var subject = new Subject { Name = "Naturwissenschaften", ShortName = "NAT" };
var group = new LearningGroup { Name = "6a", SubjectId = subject.Id };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = 2, Description = "6a auf Klassenfahrt",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), substitutions: substitutions);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 2);
Assert.True(cell.IsCancelled);
Assert.Equal("NAT", cell.SubjectLabel);
Assert.Equal("6a", cell.GroupName);
Assert.Equal("6a auf Klassenfahrt", cell.Topic);
}
[Fact]
public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde()
{
var group = new LearningGroup { Name = "6a" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 2);
Assert.False(cell.IsCancelled);
}
[Fact]
public void Load_Heute_Ausfall_ErsetztNormaleStundenanzeige()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "6a" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 2 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 2, Description = "6a auf Klassenfahrt",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions);
var item = Assert.Single(vm.TodayItems);
Assert.True(item.IsCancelled);
Assert.Equal("6a", item.GroupName);
Assert.Equal("6a auf Klassenfahrt", item.LessonTopic);
Assert.Equal(group.Id, item.GroupId);
}
[Fact]
public void Load_Heute_AusfallOhneGrund_LessonTopicBleibtLeer()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "6a" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 2 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 2 });
var vm = BuildViewModel(slots, new FakeGroups([group]), substitutions: substitutions);
var item = Assert.Single(vm.TodayItems);
Assert.False(item.HasLessonTopic);
}
}
+3
View File
@@ -133,12 +133,15 @@ public static class AppBootstrapper
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
services.AddSingleton<ITimetableSlotRepository, TimetableSlotRepository>();
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
services.AddSingleton<SchoolYearService>();
services.AddSingleton<PublicHolidayService>();
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
services.AddSingleton(_ => new PeriodScheduleService(appData));
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
services.AddSingleton(_ => new EventQueue(queuePath));
@@ -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
@@ -0,0 +1,36 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GenerateLessonSeriesDialog"
x:DataType="vm:GenerateLessonSeriesDialogViewModel"
Title="Stunden aus Stundenplan erzeugen"
Width="420" Height="300" MinWidth="380" MinHeight="280"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Stunden aus Stundenplan erzeugen" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Legt für jeden Wochentag/Stunde aus dem Stundenplan dieser Gruppe eine neue Stunde im gewählten Zeitraum an. Schulferien und Feiertage werden übersprungen, bereits vorhandene Termine nicht doppelt angelegt."/>
<Grid ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Von *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding FromDateText}" PlaceholderText="TT.MM.JJJJ"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Bis *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding ToDateText}" PlaceholderText="TT.MM.JJJJ"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="11"
TextWrapping="Wrap"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Erzeugen" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GenerateLessonSeriesDialog : Window
{
public GenerateLessonSeriesDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is GenerateLessonSeriesDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -50,7 +50,13 @@
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding TotalDurationDisplay}" FontSize="12" Opacity="0.6"/>
<TextBlock Text="{Binding TotalDurationDisplay}" FontSize="12" Opacity="0.6"
IsVisible="{Binding !HasTimeBudgetInfo}"/>
<Border Background="{Binding TimeBudgetColorHex}" CornerRadius="10" Padding="9,3"
IsVisible="{Binding HasTimeBudgetInfo}"
ToolTip.Tip="Geplante Zeit im Vergleich zur laut Stundenraster (Einstellungen) verfügbaren Zeit — bei Doppelstunden werden beide Perioden zusammengezählt.">
<TextBlock Text="{Binding TimeBudgetLabel}" FontSize="12" FontWeight="SemiBold" Foreground="White"/>
</Border>
</StackPanel>
<Button Grid.Column="1" Content=" Phase" Command="{Binding AddPhaseCommand}"/>
</Grid>
@@ -63,6 +63,8 @@
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content=" Stunde" Command="{Binding AddLessonCommand}"/>
<Button Content="Serie erzeugen" Command="{Binding GenerateLessonSeriesCommand}"
ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/>
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}"/>
@@ -1,6 +1,8 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
@@ -27,6 +29,7 @@ public partial class PlanningTabView : UserControl
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
vm.OnPickMoveTarget = ShowMoveLessonDialog;
vm.OnShowLesson = ShowLessonViewerDialog;
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
}
}
@@ -80,6 +83,8 @@ public partial class PlanningTabView : UserControl
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<IShorthandCodeRepository>(),
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<PeriodScheduleService>(),
unitId, groupId, materialSuggestions, shorthandHistorySuggestions, editingLesson);
var dialog = new LessonDialog { DataContext = dialogVm };
@@ -122,4 +127,24 @@ public partial class PlanningTabView : UserControl
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private async Task<LessonSeriesResult?> ShowGenerateLessonSeriesDialog(Unit unit)
{
var dialogVm = new GenerateLessonSeriesDialogViewModel(
App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<ISchoolHolidayRepository>(),
App.Services.GetRequiredService<PublicHolidayService>(),
App.Services.GetRequiredService<SchoolCalendarSettingsService>(),
unit.Id, unit.GroupId, unit.StartDate, unit.EndDate);
var dialog = new GenerateLessonSeriesDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var ok = await dialog.ShowDialog<bool>(owner);
if (ok && dialogVm.Result is { } result)
App.Services.GetRequiredService<NotificationService>().ShowSuccess(result.Summary);
return ok ? dialogVm.Result : null;
}
}
@@ -0,0 +1,144 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
x:Class="LehrerApp.Desktop.Views.Planning.SubstitutionEntryDialog"
x:DataType="vm:SubstitutionEntryDialogViewModel"
Title="Vertretung eintragen"
Width="440" Height="560" MinWidth="400" MinHeight="480"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14" Margin="0,0,12,0">
<TextBlock Text="Vertretung eintragen" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Für einmalige Ausnahmen — Vertretungsaufsicht, Vertretungsstunde, Sondereinsatz (Ausflug, Berufsmesse, ...) oder schlichter Ausfall an einem konkreten Tag. Wiederkehrende Aufsicht wird in den Einstellungen gepflegt."/>
<Grid ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Art *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding KindOptions}" SelectedItem="{Binding KindName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<!-- Aufsicht -->
<StackPanel Spacing="14" IsVisible="{Binding IsSupervisionKind}">
<StackPanel Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor) *" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding AfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Grund / Ort *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Description}" PlaceholderText="z.B. Vertretung für Hr. Müller, Pausenhof"/>
</StackPanel>
</StackPanel>
<!-- Stunde -->
<StackPanel Spacing="14" IsVisible="{Binding IsLessonKind}">
<StackPanel Spacing="4">
<TextBlock Text="Stunde Nr. *" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding PeriodNumber}" Minimum="1" Maximum="10" FormatString="0"
Width="140" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Eigene Gruppe (optional)" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding OwnGroups}" SelectedItem="{Binding SelectedOwnGroup}"
HorizontalAlignment="Stretch" PlaceholderText="Fremde/unbekannte Gruppe">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:LearningGroup">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Bezeichnung *" FontSize="12" Opacity="0.7"
ToolTip.Tip="Wird im Stundenplan angezeigt, z.B. Klasse/Kurs-Kürzel."/>
<TextBox Text="{Binding GroupLabel}" PlaceholderText="z.B. 8a"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Thema *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Description}" PlaceholderText="z.B. Stillarbeit, Erdkunde-Vertretung"/>
</StackPanel>
<StackPanel Spacing="6" IsVisible="{Binding CanPromoteToLesson}">
<Separator/>
<CheckBox Content="Direkt als Stunde in der Einheit übernehmen" IsChecked="{Binding PromoteToLesson}"
ToolTip.Tip="Nur sinnvoll, wenn tatsächlich echter Stoff aus der Einheit behandelt wurde — sonst bleibt es beim einfachen Plan-Eintrag."/>
<ComboBox ItemsSource="{Binding UnitsOfSelectedGroup}" SelectedItem="{Binding SelectedUnit}"
IsVisible="{Binding PromoteToLesson}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Unit">
<TextBlock Text="{Binding Title}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</StackPanel>
<!-- Sondereinsatz -->
<StackPanel Spacing="14" IsVisible="{Binding IsSpecialAssignmentKind}">
<CheckBox Content="Ganztägig" IsChecked="{Binding IsAllDay}"/>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAllDay}">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Von Stunde *" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding FromPeriod}" Minimum="1" Maximum="10" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Bis Stunde *" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding ToPeriod}" Minimum="1" Maximum="10" FormatString="0"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Eigene Gruppe (optional)" FontSize="12" Opacity="0.7"
ToolTip.Tip="Nur ausfüllen, wenn der Sondereinsatz eine konkrete Lerngruppe betrifft (z.B. Ausflug) — bei Einsätzen ohne Gruppenbezug (z.B. Berufsmesse) leer lassen."/>
<ComboBox ItemsSource="{Binding OwnGroups}" SelectedItem="{Binding SelectedOwnGroup}"
HorizontalAlignment="Stretch" PlaceholderText="Kein Gruppenbezug">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:LearningGroup">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Bezeichnung *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Description}" PlaceholderText="z.B. Ausflug ins Museum, Berufsmesse"/>
</StackPanel>
</StackPanel>
<!-- Ausfall -->
<StackPanel Spacing="14" IsVisible="{Binding IsCancelledKind}">
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Für Stunden, die schlicht ausfallen, ohne dass jemand vertritt — z.B. weil die andere Gruppe selbst nicht da ist (Klassenfahrt, Exkursion o.ä.). Fach/Klasse werden automatisch aus dem Stundenplan übernommen."/>
<StackPanel Spacing="4">
<TextBlock Text="Stunde Nr. *" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding PeriodNumber}" Minimum="1" Maximum="10" FormatString="0"
Width="140" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Grund (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Description}" PlaceholderText="z.B. 6a auf Klassenfahrt"/>
</StackPanel>
</StackPanel>
<TextBlock Text="{Binding ValidationError}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding ValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Eintragen" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Planning;
namespace LehrerApp.Desktop.Views.Planning;
public partial class SubstitutionEntryDialog : Window
{
public SubstitutionEntryDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is SubstitutionEntryDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -15,6 +15,12 @@
<Style Selector="Border.weekheader.today">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
</Style>
<Style Selector="Border.supervisioncell">
<Setter Property="Background" Value="#616161"/>
</Style>
<Style Selector="Border.supervisioncell.substitution">
<Setter Property="Background" Value="#8E24AA"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
@@ -33,6 +39,36 @@
CornerRadius="8" Padding="16" MaxHeight="240">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding TodayLabel}" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding TodaySupervisions}" Margin="0,0,0,6"
IsVisible="{Binding TodaySupervisions.Count}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TodaySupervisionItem">
<Grid ColumnDefinitions="Auto,*" Margin="0,2">
<Border Grid.Column="0" Classes="supervisioncell" Classes.substitution="{Binding IsSubstitution}"
CornerRadius="4" Padding="6,2" Margin="0,0,8,0">
<TextBlock Text="👁" FontSize="11" Foreground="White"/>
</Border>
<TextBlock Grid.Column="1" FontSize="12" VerticalAlignment="Center">
<Run Text="{Binding PeriodLabel}"/><Run Text=" — "/><Run Text="{Binding Description}"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding TodaySpecialAssignments}" Margin="0,0,0,6"
IsVisible="{Binding TodaySpecialAssignments.Count}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TodaySpecialAssignmentItem">
<Grid ColumnDefinitions="Auto,*" Margin="0,2">
<Border Grid.Column="0" Background="#00838F" CornerRadius="4" Padding="6,2" Margin="0,0,8,0">
<TextBlock Text="📌" FontSize="11" Foreground="White"/>
</Border>
<TextBlock Grid.Column="1" FontSize="12" VerticalAlignment="Center" TextWrapping="Wrap"
Text="{Binding DisplayText}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding TodayItems}">
<ItemsControl.ItemTemplate>
@@ -44,7 +80,15 @@
<TextBlock Grid.Column="1" Text="{Binding PeriodNumber}" FontSize="17" FontWeight="Bold"
Width="30" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="8,0"/>
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock Text="{Binding GroupName}" FontSize="14" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding GroupName}" FontSize="14" FontWeight="SemiBold"/>
<Border Background="#8E24AA" CornerRadius="7" Padding="5,0" IsVisible="{Binding IsSubstitution}">
<TextBlock Text="Vertretung" FontSize="10" Foreground="White"/>
</Border>
<Border Background="#757575" CornerRadius="7" Padding="5,0" IsVisible="{Binding IsCancelled}">
<TextBlock Text="Ausfall" FontSize="10" Foreground="White"/>
</Border>
</StackPanel>
<TextBlock FontSize="11" Opacity="0.6" IsVisible="{Binding HasRoom}">
<Run Text="Raum "/><Run Text="{Binding Room}"/>
</TextBlock>
@@ -54,7 +98,7 @@
IsVisible="{Binding HasExam}"/>
</StackPanel>
<Button Grid.Column="3" Content="Zur Lerngruppe" FontSize="11" Padding="9,4"
VerticalAlignment="Center"
VerticalAlignment="Center" IsVisible="{Binding HasGroupId}"
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding GroupId}"/>
</Grid>
@@ -72,7 +116,7 @@
<ScrollViewer>
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
<Button Grid.Column="0" Content="" FontWeight="Bold" Padding="10,4"
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"/>
<Button Grid.Column="1" Content="" FontWeight="Bold" Padding="10,4" Margin="4,0,0,0"
@@ -83,7 +127,9 @@
</TextBlock>
<Button Grid.Column="3" Content="Diese Woche" Margin="0,0,8,0"
Command="{Binding CurrentWeekCommand}" IsVisible="{Binding !IsCurrentWeek}"/>
<Button Grid.Column="4" Content="Stundenplan bearbeiten" Command="{Binding ShowEditorCommand}"/>
<Button Grid.Column="4" Content="Vertretung eintragen" Margin="0,0,8,0"
Command="{Binding AddSubstitutionCommand}"/>
<Button Grid.Column="5" Content="Stundenplan bearbeiten" Command="{Binding ShowEditorCommand}"/>
</Grid>
<ItemsControl ItemsSource="{Binding WeekItems}">
@@ -101,6 +147,16 @@
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsSupervisionRow}"
FontSize="10" FontWeight="SemiBold" Opacity="0.55" TextWrapping="Wrap"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border Classes="supervisioncell" Classes.substitution="{Binding IsSubstitutionSupervision}"
CornerRadius="4" Padding="4" IsVisible="{Binding IsSupervisionCell}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<TextBlock Text="{Binding SupervisionLocation}" FontSize="10" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding HasSupervision}"/>
</Border>
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
@@ -118,6 +174,8 @@
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
<TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold"
Opacity="0.9" IsVisible="{Binding IsCancelled}" Margin="0,2,0,0"/>
<StackPanel Orientation="Horizontal" Spacing="3" Margin="0,2,0,0"
IsVisible="{Binding !IsHoliday}">
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
@@ -172,6 +230,16 @@
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsSupervisionRow}"
FontSize="10" FontWeight="SemiBold" Opacity="0.55" TextWrapping="Wrap"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border Classes="supervisioncell" CornerRadius="4" Padding="4"
IsVisible="{Binding IsSupervisionCell}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<TextBlock Text="{Binding SupervisionLocation}" FontSize="10" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding HasSupervision}"/>
</Border>
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
<Panel>
@@ -13,7 +13,11 @@ public partial class TimetableView : UserControl
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is TimetableViewModel vm) vm.OnEditSlot = ShowSlotDialog;
if (DataContext is TimetableViewModel vm)
{
vm.OnEditSlot = ShowSlotDialog;
vm.OnAddSubstitution = ShowSubstitutionDialog;
}
}
private async Task ShowSlotDialog(TimetableCellItem cell)
@@ -30,4 +34,19 @@ public partial class TimetableView : UserControl
var dialog = new TimetableSlotDialog { DataContext = vm };
await dialog.ShowDialog(owner);
}
private async Task ShowSubstitutionDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var vm = new SubstitutionEntryDialogViewModel(
App.Services.GetRequiredService<ISubstitutionEntryRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IUnitRepository>(),
App.Services.GetRequiredService<ILessonRepository>());
var dialog = new SubstitutionEntryDialog { DataContext = vm };
await dialog.ShowDialog(owner);
}
}
@@ -536,6 +536,100 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Stundenraster (4.2.2 Nachtrag) -->
<ContentPage Header="Stundenraster">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Uhrzeiten der Einzelstunden" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Grundlage für die Zeitbedarf-Rückmeldung im Verlaufsplan-Editor. Nicht alle Stunden müssen eingetragen sein — für unkonfigurierte Stunden bleibt die Rückmeldung dort einfach aus."/>
<Grid ColumnDefinitions="70,*,8,*" Margin="0,4,0,0">
<TextBlock Grid.Column="1" Text="Beginn" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Grid.Column="3" Text="Ende" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
</Grid>
<ItemsControl ItemsSource="{Binding PeriodTimes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:PeriodTimeEditItem">
<Grid ColumnDefinitions="70,*,8,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding PeriodLabel}" FontSize="13" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding StartText}" PlaceholderText="HH:MM"/>
<TextBox Grid.Column="3" Text="{Binding EndText}" PlaceholderText="HH:MM"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PeriodTimesError}" Foreground="Red" FontSize="12"
IsVisible="{Binding PeriodTimesError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SavePeriodTimesCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PeriodTimesStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding PeriodTimesStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Aufsichten (4.3 Nachtrag) -->
<ContentPage Header="Aufsichten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Wiederkehrende Pausenaufsicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wird im Stundenplan zwischen den betroffenen Stunden angezeigt. Einmalige Vertretungsaufsichten trägst du direkt im Stundenplan (Heute-Ansicht) ein, nicht hier."/>
<ItemsControl ItemsSource="{Binding SupervisionDuties}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupervisionDutyItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="{Binding WeekdayLabel}"/><Run Text=" — "/><Run Text="{Binding PeriodLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Location}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSupervisionDutyCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Aufsicht hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SupervisionDuties.Count}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,8,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wochentag" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding WeekdayOptions}" SelectedItem="{Binding NewDutyWeekdayName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewDutyAfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" ShowButtonSpinner="True"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Ort / Bezeichnung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDutyLocation}" PlaceholderText="z.B. Pausenhof"/>
</StackPanel>
<TextBlock Text="{Binding NewDutyError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewDutyError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Aufsicht hinzufügen" Command="{Binding AddSupervisionDutyCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,56 @@
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class PeriodScheduleServiceTests
{
[Fact]
public void NeueKonfiguration_HatKeineStundenHinterlegt()
{
using var temp = new TempAppData();
var service = new PeriodScheduleService(temp.Path);
Assert.Empty(service.Periods);
Assert.Null(service.GetTimes(1));
Assert.Equal(0, service.GetDurationMinutes(1));
}
[Fact]
public void SetPeriods_WirdUeberNeueInstanzHinwegPersistiert()
{
using var temp = new TempAppData();
new PeriodScheduleService(temp.Path).SetPeriods(
[
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) },
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 45), End = new TimeOnly(9, 30) },
]);
var second = new PeriodScheduleService(temp.Path);
Assert.Equal(45, second.GetDurationMinutes(1));
Assert.Equal((new TimeOnly(8, 45), new TimeOnly(9, 30)), second.GetTimes(2));
}
[Fact]
public void SetPeriods_ErsetztVorherigeKonfigurationVollstaendig()
{
using var temp = new TempAppData();
var service = new PeriodScheduleService(temp.Path);
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 45), End = new TimeOnly(9, 30) }]);
Assert.Null(service.GetTimes(1));
Assert.NotNull(service.GetTimes(2));
}
private sealed class TempAppData : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
public TempAppData() => Directory.CreateDirectory(Path);
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
}
}
+144 -9
View File
@@ -248,10 +248,20 @@ gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
ins Schülerheft übertragen).
- [x] **4.2.3** Status `Planned → Conducted` setzen, Reflexionsfeld nach der Stunde.
- [x] **4.2.4** Stunden verschieben (z.B. bei Ausfall) — Folgestunden automatisch nachrücken.
- [ ] **4.2.5** Stunden serienweise aus dem Stundenplan (4.3) erzeugen. Bewusst nicht mit einem
Ersatz-Mechanismus vorgezogen — hängt an 4.3 (Wochentag/Stunden-Muster aus dem Stundenplan),
ein selbstgebautes "N Wochenstunden anlegen" wäre nur Mehrarbeit, die 4.3 später doppelt.
Einzige Möglichkeit, Stunden anzulegen, bleibt vorerst der manuelle " Stunde"-Dialog (4.2.2).
- [x] **4.2.5** Stunden serienweise aus dem Stundenplan (4.3) erzeugen — neuer Button
"Serie erzeugen" in der Stunden-Toolbar der Planung
([GenerateLessonSeriesDialog.axaml](LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml),
`GenerateLessonSeriesDialogViewModel` in
[PlanningViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs)). Legt für
jeden Wochentag/Stunde, den die Gruppe laut `TimetableSlot` (4.3) hat, im gewählten Zeitraum
eine `Lesson` an; Zeitraum ist standardmäßig `Unit.StartDate``Unit.EndDate`, falls gesetzt.
Schulferien/Feiertage werden übersprungen (dieselbe Prüfung wie im Stundenplan-Wochenraster),
bereits vorhandene Termine (gleiches Datum + gleiche Stundennummer der Gruppe, unabhängig von
der Einheit) nicht doppelt angelegt. Ergebnis ("3 Stunde(n) angelegt, 1 durch Ferien/Feiertage
übersprungen, ...") kommt als Toast (erste tatsächliche Nutzung von
`NotificationService.ShowSuccess`, bis dahin nur `ShowError` im Einsatz). Neue Stunden haben
bewusst kein Thema (Platzhalter zum Ausfüllen) — die "Thema erforderlich"-Pflicht des
manuellen " Stunde"-Dialogs gilt hier nicht.
Umgesetzt über den neuen Tab "Planung" in
[GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml)
@@ -306,6 +316,31 @@ Design-Entscheidungen:
typisierte `Lessons`-Collection, da die alten Felder nach der Modelländerung beim typisierten
Deserialisieren sonst bereits verworfen wären, bevor sie gelesen werden können.
**Nachtrag zu 4.2.2 (Stundenraster + Zeitbedarf-Rückmeldung):** Der Verlaufsplan-Editor kannte bis
dahin nur die geplante Gesamtdauer, nicht wie viel Zeit die Stunde laut Stundenplan tatsächlich
hat. Neuer Tab "Stundenraster" in den Einstellungen pflegt Beginn/Ende je Stundennummer (1.10.
Stunde, `PeriodScheduleService` — JSON-Datei, gleiches Muster wie
`SchoolCalendarSettingsService`/`PrivacySettingsService`). Nicht jede Stunde muss eingetragen sein.
- **Doppelstunden-Erkennung:** Ausgehend von der eingetragenen Stundennummer wird so lange die
jeweils nächste Periode addiert, wie der Stundenplan (4.3, `TimetableSlot`) für dieselbe Gruppe
am selben Wochentag *dort ebenfalls* einen Slot hat — eine Lesson mit Stundennummer 3 bekommt
bei einer Doppelstunde 3./4. also automatisch 90 statt 45 Minuten als Vergleichsbasis, ohne dass
das irgendwo separat markiert werden muss. Gehört die Folgeperiode einer anderen Gruppe, wird sie
korrekt nicht mitgezählt.
- **Farbskala** (`LessonDialogViewModel.TimeBudgetColor`, Nutzer-Vorgabe): 9396 % Auslastung ist
der Zielbereich (grün) — ein kleiner Puffer, da 100 % laut Nutzer "meist schon knapp" ist. Von
dort Richtung 100 % wird es zunehmend orange, darüber (überplant) kräftiger rot. Für "deutlich zu
wenig geplant" (unter 70 %) hatte der Nutzer noch keine feste Vorstellung — hier bewusst ein
neutrales Blaugrau statt Rot gewählt (kein Fehler, nur "hier geht noch was"); Grenzwerte/Farben
sind über die switch-Ausdrücke leicht nachjustierbar.
- **Beginn wird beim Setzen der Stundennummer automatisch aus dem Stundenraster übernommen**,
sofern noch keiner eingetragen ist (überschreibt nie einen bereits vorhandenen Wert) — damit
entfällt die bisher manuelle Pflege des "Beginn"-Felds für Stunden, die im Stundenraster
hinterlegt sind, ganz von selbst.
- Ohne Stundennummer/gültiges Datum oder ohne im Stundenraster hinterlegte Zeiten für die
betroffene(n) Periode(n) bleibt die Rückmeldung schlicht ausgeblendet statt eine erfundene Dauer
vorzutäuschen.
**Ideensammlung "Live-Unterrichtsmodus" (noch nicht geplant, nicht Teil von 4.2):** beim
Besprechen des Verlaufsplan-Redesigns kamen weitergehende Wünsche auf, die bewusst zurückgestellt
wurden, da sie eigene Datenmodelle (Live-Session-Zustand, Phasen-Verschiebung zwischen Stunden)
@@ -467,6 +502,76 @@ gezeigten Datum statt an "heute" — das Bearbeiten-Raster (zeigt ohnehin nur da
Muster ohne Datum) behält die alte, "heute"-verankerte Berechnung. Beim erneuten Navigieren in den
Stundenplan (Sidebar-Klick) springt die Ansicht wieder auf die laufende Woche zurück.
**Nachtrag zu 4.3, fünfte Iteration (Aufsichten + Vertretung):** Nutzer-Feedback: zwischen manchen
Stunden ist auch Pausenaufsicht zu erledigen, und gelegentlich kommen Sonderfälle vor — eine
Vertretungsaufsicht für einen erkrankten Kollegen, oder eine Vertretungsstunde in einer eigenen
oder fremden Lerngruppe.
- **Neue Modelle** ([Planning.cs](LehrerApp.Core/Models/Planning.cs)): `SupervisionDuty`
(wiederkehrend, Wochentag + "Pause nach Stunde X" + Ort — `AfterPeriod = 0` heißt Frühaufsicht
vor der 1. Stunde) und `SubstitutionEntry` (einmalig, an einem konkreten Datum — entweder
`Kind = Supervision` oder `Kind = Lesson`). Bewusst getrennt von `TimetableSlot`/`Lesson`: die
wiederkehrende Aufsicht hat keinen Gruppenbezug, und die meisten Vertretungsstunden sind keine
durchgeplanten Einheiten-Stunden.
- **Wiederkehrende Aufsicht wird nur in den Einstellungen gepflegt** (neuer Tab "Aufsichten",
analog zum Kürzel-Katalog: Liste + Formular, kein Klick-Dialog im Stundenplan-Raster selbst —
anders als bei `TimetableSlot`, weil eine kleine, seltene Liste hier ergonomischer ist als ein
Klick durchs ganze Raster). Pro Wochentag/Pause höchstens eine Aufsicht (eindeutiger Index,
freundliche Fehlermeldung wie bei `TimetableSlot`).
- **Anzeige im Stundenplan:** Aufsicht-Zeilen werden zwischen den betroffenen Stundenzeilen sowohl
im Bearbeiten-Raster als auch im Wochenraster eingefügt (nur wenn für die jeweilige Pause
tatsächlich etwas hinterlegt ist, sonst bleibt die Zeile weg) — `TimetableCellItem`/`WeekCellItem`
bekamen dafür `IsSupervisionRow`/`IsSupervisionCell` als weitere, sich gegenseitig ausschließende
Zellenarten (gleiches Muster wie die bestehenden `IsHeader`/`IsPeriodLabel`/`IsSlotCell`), statt
eine zweite `ItemsControl` neben das bestehende `UniformGrid` zu setzen.
- **"Vertretung eintragen"**-Dialog (neuer Button neben "Stundenplan bearbeiten" im "Heute"-Tab,
[SubstitutionEntryDialogViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/SubstitutionEntryDialogViewModel.cs))
deckt beide Sonderfälle ab. Eine Vertretungsaufsicht/-stunde für ein konkretes Datum überschreibt
in Wochenraster, Bearbeiten-Zeile bzw. Tagesliste die sonst dort angezeigte reguläre Information
für diese eine Stunde/Pause — sie beschreibt ja, was an dem Tag tatsächlich passiert.
Vertretungsstunden ohne passenden `TimetableSlot` (z.B. fremde Gruppe zu einer Zeit, zu der man
sonst frei hat) werden trotzdem ergänzt, nicht verworfen.
- **Entscheidung zur eigenen-Gruppe-Frage** (mit dem Nutzer abgestimmt): Standard bleibt der
einfache Weg — nur ein `SubstitutionEntry` mit Thema, wie bei einer fremden Gruppe. Nur wenn die
gewählte eigene Gruppe mindestens eine `Unit` hat, erscheint zusätzlich eine Checkbox "Direkt als
Stunde in der Einheit übernehmen" (mit Einheiten-Auswahl) — dann entsteht *zusätzlich* eine
echte `Lesson` in dieser Einheit. Der `SubstitutionEntry` bleibt in beiden Fällen bestehen (er
ist die Anzeige-Quelle für den Plan), die `Lesson` ist rein für die Fortschritts-/Reihenfolge-
Bilanz der Einheit gedacht und wird im Stundenplan nicht separat angezeigt.
**Nachtrag zu 4.3, sechste Iteration (Sondereinsätze):** Nutzer-Feedback: neben Vertretung gibt es
auch Sondereinsätze wie Ausflüge oder Berufsmessen, die einen Teil des Tages oder den ganzen Tag
blockieren, ohne dass jemand vertreten wird.
- `SubstitutionKind` um `SpecialAssignment` erweitert; `SubstitutionEntry` bekam `FromPeriod`/
`ToPeriod` (Stundenbereich) und `IsAllDay` (ganztägig statt Stundenbereich). Dieselbe dritte Art
im "Vertretung eintragen"-Dialog (`SubstitutionKindDisplay` jetzt mit drei statt zwei Optionen).
Bewusst **kein** "Als Stunde in der Einheit übernehmen" für Sondereinsätze — ein Ausflug ist
inhaltlich kein Verlaufsplan-Eintrag, anders als eine Vertretungsstunde.
- **Anzeige im Wochenraster:** ein Sondereinsatz überdeckt für seinen Wochentag jede Perioden-Zelle
im belegten Bereich (bei `IsAllDay` alle 10 Stunden) mit derselben Kachel (Farbe `#00838F`,
deutlich von Vertretung-Lila unterscheidbar) — dieselbe Überschreiben-Logik wie bei
Vertretungsstunden, nur über mehrere Perioden statt einer einzelnen.
- **Anzeige in der Tagesliste:** eigener Abschnitt `TodaySpecialAssignments` (wie bei den
Aufsichten) statt Wiederholung über mehrere Zeilen — ein ganztägiger Sondereinsatz würde sonst
zehnmal in der Liste auftauchen.
- **Mehrtägige Sondereinsätze (Klassenfahrt) bewusst nicht als Datumsbereich modelliert** — mit
dem Nutzer abgestimmt: `SubstitutionEntry.Date` bleibt ein einzelnes Datum. Klassenfahrten dauern
laut Nutzer höchstens ~5 Tage und kommen alle ein bis zwei Jahre vor — dafür lohnt sich kein
eigener Datumsbereich mit den nötigen Änderungen an `GetByDate`/Wochenraster-Abfragen; ein
Sondereinsatz über mehrere Tage wird einfach als mehrere Einzeleinträge (einer pro Tag) erfasst.
**Nachtrag zu 4.3, siebte Iteration (Stundenausfall):** Nutzer-Feedback: es kann auch sein, dass
eine Stunde schlicht ausfällt, ohne dass die eigene Abwesenheit der Grund ist — z.B. fällt der
NAT-Unterricht der 6a aus, weil die 6a selbst auf Klassenfahrt ist. Kein Vertretungsfall (niemand
übernimmt), kein Sondereinsatz (die eigene Zeit ist nicht belegt).
- `SubstitutionKind` um `Cancelled` erweitert, vierte Option im "Vertretung eintragen"-Dialog.
Braucht nur Datum + Stundennummer + optionalen Grund — Fach und Gruppe werden beim Anzeigen aus
dem an der Stelle regulär eingetragenen `TimetableSlot` abgeleitet, nicht beim Anlegen manuell
erfasst (die Information steht ja schon im Stundenplan).
Bewusst kein "Als Stunde übernehmen" — ein Ausfall ist per Definition keine gehaltene Stunde.
- **Anzeige:** ersetzt im Wochenraster und in der Tagesliste die normale Stunden-Kachel/-Zeile für
die betroffene Stunde (Grau `#757575`, Aufschrift "Ausfall") — dieselbe Überschreiben-Logik wie
bei Vertretungsstunden und Sondereinsätzen, nur dass hier nichts an die Stelle tritt.
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
@@ -507,6 +612,35 @@ Ausdrücklich als Idee für später festgehalten, **nicht** jetzt umsetzen:
sinnvoll auf die beiden Kacheln/Perioden aufteilen, damit z.B. gezielt nur die zweite Stunde
eines Blocks verschoben werden kann, ohne den ganzen Block anzufassen.
**Architekturentscheidung (Nachtrag, Konzeptgespräch):** Diskutiert wurde, ob eine eigene
Multiplattform-App für die Einheiten-/Stundenplanung sinnvoll ist — analog zum separaten
Tafelbilder-Vorhaben (eigene App, nur eine Schnittstelle zur LehrerApp, weil dort die
Interaktionsform — Zeichnen/Präsentationsmodus — grundsätzlich anders ist als CRUD). Für die
Unterrichtsplanung gilt das **nicht**: Planung findet zu 99,9 % am Desktop-PC oder MacBook statt
(Avalonia läuft dort bereits nativ), es besteht also kein Plattformzwang für eine separate App.
Entscheidung: **kein** eigenes Domainmodell/eigene App für Einheiten-/Stundenplanung — die
folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
- [ ] **4.5.7** Graph über Aktivitätsphasen und Anspruchsniveau im zeitlichen Verlauf einer
Lesson/Einheit (baut auf `LessonPhaseStep`/`Niveau` auf), um die Stundenverteilung besser
einschätzen zu können.
- [ ] **4.5.8** Kompetenzen je Aufgabe/Phase verknüpfen — nutzt den bestehenden
`CompetencyDomain`/`CompetencyItem`-Katalog (siehe Kompetenzen-Tab in den Einstellungen),
bisher nur für Klausuraufgaben (`ExamTask.CompetencyCodes`) verknüpft, nicht für
Verlaufsplan-Phasen.
- [ ] **4.5.9** KI-gestützte Planungsunterstützung über eine Schnittstelle zu einer LLM-API, um
Einheiten/Stunden mit Hilfe vorzuschlagen und weiterzuentwickeln. Bedarf eines abgesicherten
Zwischenelements auf dem eigenen Server (Ablösung/Verbesserung des bisherigen
PHP-Zwischenelements für Elternbriefe) mit interner Abrechnung/Nutzungskontrolle, damit der
API-Schlüssel nicht im Client landet.
- [ ] **4.5.10** Falls doch ein schlanker Companion-/WebApp-Client entstehen soll: bewusst
**minimaler** Funktionsumfang — nur Wochenraster ansehen, eine Stunde verschieben, oder eine
Stunde als "Umplanung nötig" flaggen. Kein Editor für Einheiten/Kompetenzen/KI-Planung dort.
Technisch schon vorbereitet: `LehrerApp.Sync`/`LehrerApp.Api` haben mit `PlainSyncEvent` und
`DeviceType.Companion` bereits eine Klartext-Sync-Schiene für genau diese Art von leichtem,
nicht-Desktop-Client (siehe `PlainEventStore`, `MapPlainSyncEndpoints`) — ein "Umplanung
nötig"-Flag käme darüber rein und würde nach dem Sync als Hinweis/Badge an der betroffenen
Stunde bzw. im Dashboard erscheinen, bis es am Desktop bearbeitet oder bewusst abgehakt wird.
---
## 5. Schülerdokumentation
@@ -1025,9 +1159,10 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
6. ~~**Kapitel 4.1 + 4.2 + 4.3** (Unterrichtsplanung: Einheiten, Einzelstunden, Stundenplan)~~
erledigt, inkl. mehrerer Nachtrag-Iterationen aus Nutzer-Feedback (Ferien-Pflege in den
Einstellungen, Wochenraster mit Wochennavigation, Ferientage ausgegraut). ~~**Kapitel 4.4.1/
4.4.2**~~ — erledigt (im Zuge der 4.3-Nachträge miterledigt). Offen bleiben **4.4.3**
(Abgabefristen im Kalender), **4.2.5** (Serienerzeugung von Stunden aus dem Stundenplan) und
**4.5** (engere Vernetzung Stundenplan ↔ Lesson-Planung — ausdrücklich vom Nutzer
zurückgestellt, nicht aus Unklarheit).
**→ nächster sinnvoller Schritt: 4.4.3 oder 4.2.5, je nach Bedarf.**
4.4.2**~~ — erledigt (im Zuge der 4.3-Nachträge miterledigt). ~~**4.2.5**~~ (Serienerzeugung
von Stunden aus dem Stundenplan) — erledigt. Offen bleiben **4.4.3** (Abgabefristen im
Kalender) und **4.5** (engere Vernetzung Stundenplan ↔ Lesson-Planung — ausdrücklich vom
Nutzer zurückgestellt, nicht aus Unklarheit; Desktop bekommt dort perspektivisch Graph/
Kompetenz-Verknüpfung/KI-Planung, ein möglicher Companion-Client bleibt bewusst minimal).
**→ nächster sinnvoller Schritt: 4.4.3, sonst weiter mit Kapitel 6/10/11.**
7. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.