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:
@@ -98,6 +98,19 @@ public interface ISchoolHolidayRepository
|
|||||||
void Save(SchoolHoliday holiday);
|
void Save(SchoolHoliday holiday);
|
||||||
void Delete(Guid id);
|
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
|
public interface IDocumentationRepository
|
||||||
{
|
{
|
||||||
List<Documentation> GetByStudent(Guid studentId);
|
List<Documentation> GetByStudent(Guid studentId);
|
||||||
|
|||||||
@@ -143,6 +143,65 @@ public enum GermanState
|
|||||||
BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH
|
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>
|
/// <summary>
|
||||||
/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr).
|
/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr).
|
||||||
/// <see cref="CalculatedValue"/> ist das zuletzt berechnete Ergebnis; <see cref="OverrideValue"/>
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -588,4 +588,61 @@ public sealed class RepositoryTests
|
|||||||
Assert.Equal("Osterferien", result[0].Name);
|
Assert.Equal("Osterferien", result[0].Name);
|
||||||
Assert.Equal("Sommerferien", result[1].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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ public class LiteDbContext : IDisposable
|
|||||||
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
|
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
|
||||||
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
|
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
|
||||||
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
|
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();
|
public void Checkpoint() => _db.Checkpoint();
|
||||||
|
|
||||||
@@ -374,6 +376,9 @@ public class LiteDbContext : IDisposable
|
|||||||
TimetableSlots.EnsureIndex(x => x.GroupId);
|
TimetableSlots.EnsureIndex(x => x.GroupId);
|
||||||
TimetableSlots.EnsureIndex("ux_timetable_weekday_period",
|
TimetableSlots.EnsureIndex("ux_timetable_weekday_period",
|
||||||
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true);
|
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();
|
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 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 class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
|
|||||||
@@ -243,6 +243,32 @@ public class FakeSchoolHolidays : ISchoolHolidayRepository
|
|||||||
public void Delete(Guid id) => _all.RemoveAll(h => h.Id == id);
|
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
|
public class FakeReportGrades : IReportGradeRepository
|
||||||
{
|
{
|
||||||
private readonly List<ReportGrade> _all = [];
|
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.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -6,8 +7,18 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
|
|
||||||
public sealed class LessonDialogViewModelTests
|
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([]),
|
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||||
|
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||||
unitId, groupId, [], [], editing);
|
unitId, groupId, [], [], editing);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -71,6 +82,7 @@ public sealed class LessonDialogViewModelTests
|
|||||||
{
|
{
|
||||||
var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
|
var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
|
||||||
var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]),
|
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
|
Guid.NewGuid(), Guid.NewGuid(), [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen
|
||||||
|
|
||||||
Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
|
Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
|
||||||
@@ -133,7 +145,7 @@ public sealed class LessonDialogViewModelTests
|
|||||||
var groupId = Guid.NewGuid();
|
var groupId = Guid.NewGuid();
|
||||||
var lessons = new FakeLessons();
|
var lessons = new FakeLessons();
|
||||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
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",
|
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,
|
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.True(vm.Phases[0].HasAlternativePath);
|
||||||
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName);
|
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);
|
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]
|
[Fact]
|
||||||
public void LoadUnits_SammeltKurzsymboleAusAllenStundenphasenDerGruppe()
|
public void LoadUnits_SammeltKurzsymboleAusAllenStundenphasenDerGruppe()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
|
|
||||||
public sealed class SettingsViewModelTests
|
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,
|
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
|
||||||
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||||
@@ -22,7 +23,8 @@ public sealed class SettingsViewModelTests
|
|||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
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]
|
[Fact]
|
||||||
@@ -97,10 +99,123 @@ public sealed class SettingsViewModelTests
|
|||||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings);
|
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||||
|
new FakeSupervisionDuties());
|
||||||
|
|
||||||
vm.SelectedStateName = "Bayern";
|
vm.SelectedStateName = "Bayern";
|
||||||
|
|
||||||
Assert.Equal(GermanState.BY, calendarSettings.State);
|
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(
|
private static TimetableViewModel BuildViewModel(
|
||||||
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
|
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
|
||||||
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = 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
|
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
|
||||||
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
// (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([]),
|
lessons ?? new FakeLessons(), exams ?? new FakeExams([]),
|
||||||
holidays ?? new FakeSchoolHolidays(),
|
holidays ?? new FakeSchoolHolidays(),
|
||||||
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
|
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 —
|
/// 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);
|
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
|
||||||
Assert.False(cell.HasBadge);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,12 +133,15 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
|
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
|
||||||
services.AddSingleton<ITimetableSlotRepository, TimetableSlotRepository>();
|
services.AddSingleton<ITimetableSlotRepository, TimetableSlotRepository>();
|
||||||
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
|
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
|
||||||
|
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
|
||||||
|
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────
|
||||||
services.AddSingleton<GradingService>();
|
services.AddSingleton<GradingService>();
|
||||||
services.AddSingleton<SchoolYearService>();
|
services.AddSingleton<SchoolYearService>();
|
||||||
services.AddSingleton<PublicHolidayService>();
|
services.AddSingleton<PublicHolidayService>();
|
||||||
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
|
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
|
||||||
|
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
||||||
|
|
||||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||||
|
|||||||
@@ -2,16 +2,24 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
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 MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing);
|
||||||
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
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) ────
|
// ── Tab-ViewModel: Unterrichtsplanung (4.1 Einheiten / 4.2 Einzelstunden) ────
|
||||||
|
|
||||||
public partial class PlanningTabViewModel : ObservableObject
|
public partial class PlanningTabViewModel : ObservableObject
|
||||||
@@ -56,6 +64,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
|
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
|
||||||
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
||||||
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
||||||
|
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||||
|
|
||||||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||||||
IGroupRepository groups, ISubjectRepository subjects,
|
IGroupRepository groups, ISubjectRepository subjects,
|
||||||
@@ -109,6 +118,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
DeleteUnitCommand.NotifyCanExecuteChanged();
|
DeleteUnitCommand.NotifyCanExecuteChanged();
|
||||||
CopyUnitCommand.NotifyCanExecuteChanged();
|
CopyUnitCommand.NotifyCanExecuteChanged();
|
||||||
AddLessonCommand.NotifyCanExecuteChanged();
|
AddLessonCommand.NotifyCanExecuteChanged();
|
||||||
|
GenerateLessonSeriesCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadLessons()
|
private void LoadLessons()
|
||||||
@@ -231,6 +241,16 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
if (await OnAddLesson(SelectedUnit.Id, _groupId, KnownMaterials, KnownShorthands)) LoadUnits();
|
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))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private async Task EditLesson()
|
private async Task EditLesson()
|
||||||
{
|
{
|
||||||
@@ -558,6 +578,8 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly ILessonRepository _lessons;
|
private readonly ILessonRepository _lessons;
|
||||||
private readonly IAlternativeLessonPathRepository _alternativePaths;
|
private readonly IAlternativeLessonPathRepository _alternativePaths;
|
||||||
|
private readonly ITimetableSlotRepository _timetableSlots;
|
||||||
|
private readonly PeriodScheduleService _periodSchedule;
|
||||||
private readonly Guid _unitId;
|
private readonly Guid _unitId;
|
||||||
private readonly Guid _groupId;
|
private readonly Guid _groupId;
|
||||||
private readonly Lesson? _editingLesson;
|
private readonly Lesson? _editingLesson;
|
||||||
@@ -573,6 +595,9 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _topicError = "";
|
[ObservableProperty] private string _topicError = "";
|
||||||
[ObservableProperty] private string _startTimeTextError = "";
|
[ObservableProperty] private string _startTimeTextError = "";
|
||||||
[ObservableProperty] private string _totalDurationDisplay = "0 Minuten gesamt";
|
[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[] StatusOptions => LessonStatusDisplay.Options;
|
||||||
public string[] MaterialSuggestions { get; }
|
public string[] MaterialSuggestions { get; }
|
||||||
@@ -589,10 +614,12 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern";
|
public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern";
|
||||||
|
|
||||||
public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes,
|
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)
|
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
||||||
{
|
{
|
||||||
_lessons = lessons; _alternativePaths = alternativePaths;
|
_lessons = lessons; _alternativePaths = alternativePaths;
|
||||||
|
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||||
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
|
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
|
||||||
MaterialSuggestions = [.. materialSuggestions];
|
MaterialSuggestions = [.. materialSuggestions];
|
||||||
|
|
||||||
@@ -672,6 +699,18 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
partial void OnStartTimeTextChanged(string value) => RecomputeTimes();
|
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
|
/// 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).
|
/// 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}" : "";
|
p.ComputedTimeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : "";
|
||||||
if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes);
|
if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecomputeTimeBudget(total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vergleicht die geplante Gesamtdauer mit der laut Stundenraster (Einstellungen) tatsächlich
|
||||||
|
/// verfügbaren Zeit — Doppelstunden werden erkannt, indem ab der eingetragenen Stundennummer
|
||||||
|
/// so lange die jeweils nächste Periode addiert wird, wie der Stundenplan (4.3) für dieselbe
|
||||||
|
/// Gruppe/denselben Wochentag auch dort einen Slot hat (siehe <see cref="TimetableSlot"/>).
|
||||||
|
/// Ohne erkennbare Stunde/Datum oder ohne im Stundenraster hinterlegte Uhrzeiten bleibt die
|
||||||
|
/// Rückmeldung schlicht ausgeblendet, statt eine erfundene Dauer vorzutäuschen.
|
||||||
|
/// </summary>
|
||||||
|
private void RecomputeTimeBudget(int plannedMinutes)
|
||||||
|
{
|
||||||
|
if (LessonNumber is not int startPeriod ||
|
||||||
|
!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||||
|
{ HasTimeBudgetInfo = false; return; }
|
||||||
|
|
||||||
|
var weekday = date.DayOfWeek;
|
||||||
|
var groupSlotsByPeriod = _timetableSlots.GetByGroup(_groupId)
|
||||||
|
.Where(s => s.Weekday == weekday)
|
||||||
|
.ToDictionary(s => s.PeriodNumber);
|
||||||
|
|
||||||
|
var available = _periodSchedule.GetDurationMinutes(startPeriod);
|
||||||
|
var period = startPeriod + 1;
|
||||||
|
while (groupSlotsByPeriod.ContainsKey(period))
|
||||||
|
{
|
||||||
|
available += _periodSchedule.GetDurationMinutes(period);
|
||||||
|
period++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available <= 0) { HasTimeBudgetInfo = false; return; }
|
||||||
|
|
||||||
|
var utilizationPercent = (double)plannedMinutes / available * 100;
|
||||||
|
TimeBudgetColorHex = TimeBudgetColor(utilizationPercent);
|
||||||
|
TimeBudgetLabel = $"{plannedMinutes} von {available} Minuten geplant ({utilizationPercent:0}%)";
|
||||||
|
HasTimeBudgetInfo = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Farbskala für die Auslastung (geplante / verfügbare Minuten): 93–96 % gilt als guter
|
||||||
|
/// Zielbereich (grün) — ein kleiner Puffer, da 100 % erfahrungsgemäß schon knapp ist. Darüber
|
||||||
|
/// wird es zunehmend rötlich, deutlich über 100 % kräftig rot. Deutlich unter 93 % (zu viel
|
||||||
|
/// Luft) ist bewusst neutral/blau statt rot gehalten — kein Fehler, nur "hier geht noch was".
|
||||||
|
/// </summary>
|
||||||
|
private static string TimeBudgetColor(double utilizationPercent) => utilizationPercent switch
|
||||||
|
{
|
||||||
|
< 70 => "#90A4AE", // Blaugrau: deutlich zu wenig geplant
|
||||||
|
< 93 => "#FFC107", // Gelb: noch Luft nach oben
|
||||||
|
<= 96 => "#43A047", // Grün: guter Zielbereich
|
||||||
|
<= 100 => "#FB8C00", // Orange: knapp, kaum Puffer
|
||||||
|
<= 115 => "#E64A19", // Rotorange: leicht überplant
|
||||||
|
_ => "#B71C1C", // Dunkelrot: deutlich überplant
|
||||||
|
};
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
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) ────────────
|
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
|
||||||
|
|
||||||
public partial class CopyUnitDialogViewModel : ObservableObject
|
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 SchoolCalendarSettingsService _calendarSettings;
|
||||||
private readonly PublicHolidayService _publicHolidays;
|
private readonly PublicHolidayService _publicHolidays;
|
||||||
private readonly SchoolYearService _schoolYear;
|
private readonly SchoolYearService _schoolYear;
|
||||||
|
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||||
|
private readonly ISubstitutionEntryRepository _substitutions;
|
||||||
|
|
||||||
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
|
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
|
||||||
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
|
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
|
||||||
public ObservableCollection<HoursWarningItem> HoursWarnings { get; } = [];
|
public ObservableCollection<HoursWarningItem> HoursWarnings { get; } = [];
|
||||||
public ObservableCollection<TodayLessonItem> TodayItems { get; } = [];
|
public ObservableCollection<TodayLessonItem> TodayItems { get; } = [];
|
||||||
|
public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = [];
|
||||||
|
public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty] private int _activeTabIndex;
|
[ObservableProperty] private int _activeTabIndex;
|
||||||
[ObservableProperty] private string _todayLabel = "";
|
[ObservableProperty] private string _todayLabel = "";
|
||||||
@@ -72,15 +76,18 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
|
|
||||||
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
|
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
|
||||||
public Action<Guid>? OnNavigateToGroup { get; set; }
|
public Action<Guid>? OnNavigateToGroup { get; set; }
|
||||||
|
public Func<Task>? OnAddSubstitution { get; set; }
|
||||||
|
|
||||||
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
|
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
|
||||||
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
|
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
|
||||||
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
|
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;
|
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
|
||||||
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
|
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
|
||||||
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
|
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
|
||||||
|
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +102,14 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CurrentWeek() { WeekOffset = 0; Load(); }
|
private void CurrentWeek() { WeekOffset = 0; Load(); }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddSubstitution()
|
||||||
|
{
|
||||||
|
if (OnAddSubstitution is null) return;
|
||||||
|
await OnAddSubstitution();
|
||||||
|
Load();
|
||||||
|
}
|
||||||
|
|
||||||
public void Load()
|
public void Load()
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
@@ -106,33 +121,90 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
|
|
||||||
var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates);
|
var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates);
|
||||||
var examProximity = ComputeExamProximity(today, publicHolidayDates);
|
var examProximity = ComputeExamProximity(today, publicHolidayDates);
|
||||||
|
var duties = _supervisionDuties.GetAll();
|
||||||
|
|
||||||
BuildGrid(holidayBadges);
|
BuildGrid(holidayBadges, duties);
|
||||||
BuildWeekOverview(today, publicHolidayDates);
|
BuildWeekOverview(today, publicHolidayDates, duties);
|
||||||
BuildToday(today);
|
BuildToday(today);
|
||||||
BuildHoursWarnings();
|
BuildHoursWarnings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── "Heute": Tagesliste ──────────────────────────────────────────────────
|
// ── "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)
|
private void BuildToday(DateOnly today)
|
||||||
{
|
{
|
||||||
TodayItems.Clear();
|
var substitutionsToday = _substitutions.GetByDate(today);
|
||||||
var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).OrderBy(s => s.PeriodNumber).ToList();
|
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)
|
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);
|
var group = _groups.GetById(slot.GroupId);
|
||||||
if (group is null) continue;
|
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 lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
|
||||||
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
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));
|
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]
|
[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]
|
[RelayCommand]
|
||||||
private void ShowEditor() => ActiveTabIndex = 1;
|
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
|
/// 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,
|
/// 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,
|
/// 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>
|
/// </summary>
|
||||||
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates)
|
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates, List<SupervisionDuty> duties)
|
||||||
{
|
{
|
||||||
WeekItems.Clear();
|
WeekItems.Clear();
|
||||||
var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
|
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 groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id);
|
||||||
var schoolHolidays = _schoolHolidays.GetAll();
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
var dateByWeekday = Weekdays.ToDictionary(w => w, w => monday.AddDays((int)w - (int)DayOfWeek.Monday));
|
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());
|
WeekItems.Add(WeekCellItem.Corner());
|
||||||
foreach (var weekday in Weekdays)
|
foreach (var weekday in Weekdays)
|
||||||
WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today));
|
WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today));
|
||||||
|
|
||||||
|
AddWeekSupervisionRowIfAny(0, dutiesByPeriod, substitutionsThisWeek, dateByWeekday);
|
||||||
|
|
||||||
for (var period = FirstPeriod; period <= LastPeriod; period++)
|
for (var period = FirstPeriod; period <= LastPeriod; period++)
|
||||||
{
|
{
|
||||||
WeekItems.Add(WeekCellItem.PeriodLabel(period));
|
WeekItems.Add(WeekCellItem.PeriodLabel(period));
|
||||||
foreach (var weekday in Weekdays)
|
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);
|
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
|
||||||
if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; }
|
if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; }
|
||||||
|
|
||||||
var date = dateByWeekday[weekday];
|
|
||||||
var group = groups.GetValueOrDefault(slot.GroupId);
|
var group = groups.GetValueOrDefault(slot.GroupId);
|
||||||
var subject = group?.SubjectId is { } subjectId ? _subjects.GetById(subjectId) : null;
|
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 lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
|
||||||
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
||||||
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
||||||
@@ -189,6 +295,30 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
||||||
MentionsExperiment(lesson), slot.GroupId, isHoliday));
|
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 ─────────────────────────────────────────────────────
|
// ── 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();
|
Cells.Clear();
|
||||||
var allSlots = _slots.GetAll();
|
var allSlots = _slots.GetAll();
|
||||||
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
|
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
|
||||||
|
var dutiesByPeriod = duties.ToLookup(d => d.AfterPeriod);
|
||||||
|
|
||||||
Cells.Add(TimetableCellItem.Corner());
|
Cells.Add(TimetableCellItem.Corner());
|
||||||
foreach (var weekday in Weekdays)
|
foreach (var weekday in Weekdays)
|
||||||
Cells.Add(TimetableCellItem.WeekdayHeader(weekday));
|
Cells.Add(TimetableCellItem.WeekdayHeader(weekday));
|
||||||
|
|
||||||
|
AddGridSupervisionRowIfAny(0, dutiesByPeriod);
|
||||||
|
|
||||||
for (var period = FirstPeriod; period <= LastPeriod; period++)
|
for (var period = FirstPeriod; period <= LastPeriod; period++)
|
||||||
{
|
{
|
||||||
Cells.Add(TimetableCellItem.PeriodLabel(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), "") : "";
|
var badge = slot is not null ? badges.GetValueOrDefault((weekday, slot.GroupId), "") : "";
|
||||||
Cells.Add(TimetableCellItem.ForSlot(weekday, period, slot, groupName, ColorFor(groupName), badge));
|
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 IsHeader { get; private init; }
|
||||||
public bool IsPeriodLabel { 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 string Text { get; private init; } = "";
|
||||||
public DayOfWeek? Weekday { get; private init; }
|
public DayOfWeek? Weekday { get; private init; }
|
||||||
public int PeriodNumber { get; private init; }
|
public int PeriodNumber { get; private init; }
|
||||||
@@ -352,9 +502,11 @@ public class TimetableCellItem
|
|||||||
public string GroupName { get; private init; } = "";
|
public string GroupName { get; private init; } = "";
|
||||||
public string ColorHex { get; private init; } = "#9E9E9E";
|
public string ColorHex { get; private init; } = "#9E9E9E";
|
||||||
public string BadgeText { get; private init; } = "";
|
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 HasBadge => BadgeText.Length > 0;
|
||||||
public bool IsAssigned => Slot is not null;
|
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 = "" };
|
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 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,
|
public static TimetableCellItem ForSlot(DayOfWeek day, int period, TimetableSlot? slot, string groupName,
|
||||||
string colorHex, string badgeText) => new()
|
string colorHex, string badgeText) => new()
|
||||||
{
|
{
|
||||||
@@ -384,8 +548,14 @@ public class WeekCellItem
|
|||||||
{
|
{
|
||||||
public bool IsHeader { get; private init; }
|
public bool IsHeader { get; private init; }
|
||||||
public bool IsPeriodLabel { 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 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 string Text { get; private init; } = "";
|
||||||
public DayOfWeek? Weekday { get; private init; }
|
public DayOfWeek? Weekday { get; private init; }
|
||||||
public int PeriodNumber { get; private init; }
|
public int PeriodNumber { get; private init; }
|
||||||
@@ -402,6 +572,8 @@ public class WeekCellItem
|
|||||||
public bool IsLastBeforeExam { get; private init; }
|
public bool IsLastBeforeExam { get; private init; }
|
||||||
public bool HasExperiment { get; private init; }
|
public bool HasExperiment { get; private init; }
|
||||||
public bool IsHoliday { 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 HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||||
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
|
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 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,
|
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday) => new()
|
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday) => new()
|
||||||
@@ -432,6 +617,30 @@ public class WeekCellItem
|
|||||||
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
||||||
IsHoliday = isHoliday,
|
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)
|
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 string Text { get; } = $"{groupName}: {assigned} von {expected} Wochenstunden eingetragen";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
|
public class TodayLessonItem
|
||||||
string colorHex, string? lessonTopic, string? examTitle)
|
|
||||||
{
|
{
|
||||||
public Guid GroupId { get; } = groupId;
|
public Guid GroupId { get; private init; }
|
||||||
public int PeriodNumber { get; } = periodNumber;
|
public int PeriodNumber { get; private init; }
|
||||||
public string GroupName { get; } = groupName;
|
public string GroupName { get; private init; } = "";
|
||||||
public string Room { get; } = room;
|
public string Room { get; private init; } = "";
|
||||||
public string ColorHex { get; } = colorHex;
|
public string ColorHex { get; private init; } = "#9E9E9E";
|
||||||
public string? LessonTopic { get; } = lessonTopic;
|
public string? LessonTopic { get; private init; }
|
||||||
public string? ExamTitle { get; } = examTitle;
|
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 HasRoom => !string.IsNullOrWhiteSpace(Room);
|
||||||
public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic);
|
public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic);
|
||||||
public bool HasExam => ExamTitle is not null;
|
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 List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
|
||||||
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
|
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 ───────────────────────────────────────────────────────────
|
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||||
|
private readonly PeriodScheduleService _periodSchedule;
|
||||||
|
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||||
|
|
||||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||||||
@@ -138,7 +157,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
|
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
|
||||||
IDocumentationRepository documentation, IStudentRepository students,
|
IDocumentationRepository documentation, IStudentRepository students,
|
||||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||||
SchoolCalendarSettingsService calendarSettings)
|
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||||
|
ISupervisionDutyRepository supervisionDuties)
|
||||||
{
|
{
|
||||||
_subjects = subjects;
|
_subjects = subjects;
|
||||||
_domainRepo = domainRepo;
|
_domainRepo = domainRepo;
|
||||||
@@ -155,6 +175,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_shorthandCodes = shorthandCodes;
|
_shorthandCodes = shorthandCodes;
|
||||||
_schoolHolidays = schoolHolidays;
|
_schoolHolidays = schoolHolidays;
|
||||||
_calendarSettings = calendarSettings;
|
_calendarSettings = calendarSettings;
|
||||||
|
_periodSchedule = periodSchedule;
|
||||||
|
_supervisionDuties = supervisionDuties;
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
LoadShorthandCodes();
|
LoadShorthandCodes();
|
||||||
LoadGradingKeyTemplates();
|
LoadGradingKeyTemplates();
|
||||||
@@ -167,6 +189,84 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
LoadExpiredDocuments();
|
LoadExpiredDocuments();
|
||||||
SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State);
|
SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State);
|
||||||
LoadSchoolHolidays();
|
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 ─────────────────
|
// ── 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 string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} – {h.EndDate:dd.MM.yyyy}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public partial class PeriodTimeEditItem : ObservableObject
|
||||||
|
{
|
||||||
|
public int PeriodNumber { get; }
|
||||||
|
public string PeriodLabel { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string _startText;
|
||||||
|
[ObservableProperty] private string _endText;
|
||||||
|
|
||||||
|
public PeriodTimeEditItem(int periodNumber, TimeOnly? start, TimeOnly? end)
|
||||||
|
{
|
||||||
|
PeriodNumber = periodNumber;
|
||||||
|
PeriodLabel = $"{periodNumber}. Stunde";
|
||||||
|
_startText = start?.ToString("HH:mm") ?? "";
|
||||||
|
_endText = end?.ToString("HH:mm") ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wochentag: deutsche Anzeige (Mo–Fr, für Aufsichten) ────────────────────────
|
||||||
|
|
||||||
|
public static class WeekdayDisplay
|
||||||
|
{
|
||||||
|
private static readonly (DayOfWeek Day, string Name)[] Entries =
|
||||||
|
[
|
||||||
|
(DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"),
|
||||||
|
(DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
|
||||||
|
public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name;
|
||||||
|
public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SupervisionDutyItem(SupervisionDuty d)
|
||||||
|
{
|
||||||
|
public Guid Id { get; } = d.Id;
|
||||||
|
public string WeekdayLabel { get; } = WeekdayDisplay.Label(d.Weekday);
|
||||||
|
public int AfterPeriod { get; } = d.AfterPeriod;
|
||||||
|
public string PeriodLabel { get; } = d.AfterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {d.AfterPeriod}. Stunde";
|
||||||
|
public string Location { get; } = d.Location;
|
||||||
|
}
|
||||||
|
|
||||||
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
|
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
internal class CatalogDto
|
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">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/>
|
<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>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="+ Phase" Command="{Binding AddPhaseCommand}"/>
|
<Button Grid.Column="1" Content="+ Phase" Command="{Binding AddPhaseCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -63,6 +63,8 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
<Button Content="+ Stunde" Command="{Binding AddLessonCommand}"/>
|
<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}"
|
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
|
||||||
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
||||||
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}"/>
|
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}"/>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -27,6 +29,7 @@ public partial class PlanningTabView : UserControl
|
|||||||
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
|
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
|
||||||
vm.OnPickMoveTarget = ShowMoveLessonDialog;
|
vm.OnPickMoveTarget = ShowMoveLessonDialog;
|
||||||
vm.OnShowLesson = ShowLessonViewerDialog;
|
vm.OnShowLesson = ShowLessonViewerDialog;
|
||||||
|
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +83,8 @@ public partial class PlanningTabView : UserControl
|
|||||||
App.Services.GetRequiredService<ILessonRepository>(),
|
App.Services.GetRequiredService<ILessonRepository>(),
|
||||||
App.Services.GetRequiredService<IShorthandCodeRepository>(),
|
App.Services.GetRequiredService<IShorthandCodeRepository>(),
|
||||||
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
|
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
|
||||||
|
App.Services.GetRequiredService<ITimetableSlotRepository>(),
|
||||||
|
App.Services.GetRequiredService<PeriodScheduleService>(),
|
||||||
unitId, groupId, materialSuggestions, shorthandHistorySuggestions, editingLesson);
|
unitId, groupId, materialSuggestions, shorthandHistorySuggestions, editingLesson);
|
||||||
|
|
||||||
var dialog = new LessonDialog { DataContext = dialogVm };
|
var dialog = new LessonDialog { DataContext = dialogVm };
|
||||||
@@ -122,4 +127,24 @@ public partial class PlanningTabView : UserControl
|
|||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner is not null) await dialog.ShowDialog(owner);
|
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">
|
<Style Selector="Border.weekheader.today">
|
||||||
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
|
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Border.supervisioncell">
|
||||||
|
<Setter Property="Background" Value="#616161"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.supervisioncell.substitution">
|
||||||
|
<Setter Property="Background" Value="#8E24AA"/>
|
||||||
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*">
|
<Grid RowDefinitions="Auto,*">
|
||||||
@@ -33,6 +39,36 @@
|
|||||||
CornerRadius="8" Padding="16" MaxHeight="240">
|
CornerRadius="8" Padding="16" MaxHeight="240">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Top" Text="{Binding TodayLabel}" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
<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>
|
<ScrollViewer>
|
||||||
<ItemsControl ItemsSource="{Binding TodayItems}">
|
<ItemsControl ItemsSource="{Binding TodayItems}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
@@ -44,7 +80,15 @@
|
|||||||
<TextBlock Grid.Column="1" Text="{Binding PeriodNumber}" FontSize="17" FontWeight="Bold"
|
<TextBlock Grid.Column="1" Text="{Binding PeriodNumber}" FontSize="17" FontWeight="Bold"
|
||||||
Width="30" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="8,0"/>
|
Width="30" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="8,0"/>
|
||||||
<StackPanel Grid.Column="2" Spacing="2">
|
<StackPanel Grid.Column="2" Spacing="2">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Text="{Binding GroupName}" FontSize="14" FontWeight="SemiBold"/>
|
<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}">
|
<TextBlock FontSize="11" Opacity="0.6" IsVisible="{Binding HasRoom}">
|
||||||
<Run Text="Raum "/><Run Text="{Binding Room}"/>
|
<Run Text="Raum "/><Run Text="{Binding Room}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
@@ -54,7 +98,7 @@
|
|||||||
IsVisible="{Binding HasExam}"/>
|
IsVisible="{Binding HasExam}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="3" Content="Zur Lerngruppe" FontSize="11" Padding="9,4"
|
<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}"
|
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).OpenGroupCommand}"
|
||||||
CommandParameter="{Binding GroupId}"/>
|
CommandParameter="{Binding GroupId}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -72,7 +116,7 @@
|
|||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="14">
|
<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"
|
<Button Grid.Column="0" Content="‹" FontWeight="Bold" Padding="10,4"
|
||||||
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"/>
|
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"/>
|
||||||
<Button Grid.Column="1" Content="›" FontWeight="Bold" Padding="10,4" Margin="4,0,0,0"
|
<Button Grid.Column="1" Content="›" FontWeight="Bold" Padding="10,4" Margin="4,0,0,0"
|
||||||
@@ -83,7 +127,9 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
<Button Grid.Column="3" Content="Diese Woche" Margin="0,0,8,0"
|
<Button Grid.Column="3" Content="Diese Woche" Margin="0,0,8,0"
|
||||||
Command="{Binding CurrentWeekCommand}" IsVisible="{Binding !IsCurrentWeek}"/>
|
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>
|
</Grid>
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding WeekItems}">
|
<ItemsControl ItemsSource="{Binding WeekItems}">
|
||||||
@@ -101,6 +147,16 @@
|
|||||||
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
|
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
|
||||||
FontWeight="SemiBold" FontSize="13"
|
FontWeight="SemiBold" FontSize="13"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
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}"
|
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
||||||
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
||||||
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
|
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
|
||||||
@@ -118,6 +174,8 @@
|
|||||||
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
||||||
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||||
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
|
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"
|
<StackPanel Orientation="Horizontal" Spacing="3" Margin="0,2,0,0"
|
||||||
IsVisible="{Binding !IsHoliday}">
|
IsVisible="{Binding !IsHoliday}">
|
||||||
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
|
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
|
||||||
@@ -172,6 +230,16 @@
|
|||||||
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
|
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
|
||||||
FontWeight="SemiBold" FontSize="13"
|
FontWeight="SemiBold" FontSize="13"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
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}"
|
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
||||||
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ public partial class TimetableView : UserControl
|
|||||||
protected override void OnDataContextChanged(EventArgs e)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnDataContextChanged(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)
|
private async Task ShowSlotDialog(TimetableCellItem cell)
|
||||||
@@ -30,4 +34,19 @@ public partial class TimetableView : UserControl
|
|||||||
var dialog = new TimetableSlotDialog { DataContext = vm };
|
var dialog = new TimetableSlotDialog { DataContext = vm };
|
||||||
await dialog.ShowDialog(owner);
|
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>
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</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>
|
</TabbedPage>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,10 +248,20 @@ gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
|
|||||||
ins Schülerheft übertragen).
|
ins Schülerheft übertragen).
|
||||||
- [x] **4.2.3** Status `Planned → Conducted` setzen, Reflexionsfeld nach der Stunde.
|
- [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.
|
- [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
|
- [x] **4.2.5** Stunden serienweise aus dem Stundenplan (4.3) erzeugen — neuer Button
|
||||||
Ersatz-Mechanismus vorgezogen — hängt an 4.3 (Wochentag/Stunden-Muster aus dem Stundenplan),
|
"Serie erzeugen" in der Stunden-Toolbar der Planung
|
||||||
ein selbstgebautes "N Wochenstunden anlegen" wäre nur Mehrarbeit, die 4.3 später doppelt.
|
([GenerateLessonSeriesDialog.axaml](LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml),
|
||||||
Einzige Möglichkeit, Stunden anzulegen, bleibt vorerst der manuelle "+ Stunde"-Dialog (4.2.2).
|
`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
|
Umgesetzt über den neuen Tab "Planung" in
|
||||||
[GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml)
|
[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
|
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.
|
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): 93–96 % 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
|
**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
|
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)
|
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
|
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.
|
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
|
### 4.4 Wochen-/Tagesansicht
|
||||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
- [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.
|
("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
|
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.
|
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
|
## 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)~~ —
|
6. ~~**Kapitel 4.1 + 4.2 + 4.3** (Unterrichtsplanung: Einheiten, Einzelstunden, Stundenplan)~~ —
|
||||||
erledigt, inkl. mehrerer Nachtrag-Iterationen aus Nutzer-Feedback (Ferien-Pflege in den
|
erledigt, inkl. mehrerer Nachtrag-Iterationen aus Nutzer-Feedback (Ferien-Pflege in den
|
||||||
Einstellungen, Wochenraster mit Wochennavigation, Ferientage ausgegraut). ~~**Kapitel 4.4.1/
|
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**
|
4.4.2**~~ — erledigt (im Zuge der 4.3-Nachträge miterledigt). ~~**4.2.5**~~ (Serienerzeugung
|
||||||
(Abgabefristen im Kalender), **4.2.5** (Serienerzeugung von Stunden aus dem Stundenplan) und
|
von Stunden aus dem Stundenplan) — erledigt. Offen bleiben **4.4.3** (Abgabefristen im
|
||||||
**4.5** (engere Vernetzung Stundenplan ↔ Lesson-Planung — ausdrücklich vom Nutzer
|
Kalender) und **4.5** (engere Vernetzung Stundenplan ↔ Lesson-Planung — ausdrücklich vom
|
||||||
zurückgestellt, nicht aus Unklarheit).
|
Nutzer zurückgestellt, nicht aus Unklarheit; Desktop bekommt dort perspektivisch Graph/
|
||||||
**→ nächster sinnvoller Schritt: 4.4.3 oder 4.2.5, je nach Bedarf.**
|
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.
|
7. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.
|
||||||
|
|||||||
Reference in New Issue
Block a user