From ee1a47641e6807b0d4e267aad29325c187f06a52 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Fri, 14 Aug 2026 01:53:12 +0200 Subject: [PATCH] Stundenverlaufsplan: Viewer und Alternativpfad-Katalog (Kapitel 4.2 Nachtrag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schreibgeschützter LessonViewerDialog (größere Schrift, ohne Bearbeitungs-/Verlängern-/ Verschieben-Funktion) für den Einsatz während des Unterrichtens, erreichbar über "Anzeigen" im Stunden-Toolbar. Alternative Unterrichtsabläufe (z.B. Kurzversion bei Zeitmangel) laufen jetzt über einen echten Katalog (neues Modell AlternativeLessonPath: Name + Beschreibung) statt Freitext direkt an der Phase: im Verlaufsplan-Editor eine kompakte, farbig unterstützte Checkbox statt einer durchgehend sichtbaren Eingabespalte, Zuordnung/Neuanlage über einen eigenen Dialog. Der Viewer gruppiert Phasen entsprechend und zeigt die hinterlegte Beschreibung. Schema-Migration v3→v4 führt bestehende Freitextwerte verlustfrei in Katalogeinträge über. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 7 + LehrerApp.Core/Models/Planning.cs | 26 +++ LehrerApp.Data.Tests/LiteDbContextTests.cs | 58 ++++- LehrerApp.Data.Tests/RepositoryTests.cs | 41 ++++ LehrerApp.Data/LiteDbContext.cs | 49 +++- .../Repositories/AllRepositories.cs | 19 ++ LehrerApp.Desktop.Tests/Fakes.cs | 8 + .../LessonDialogViewModelTests.cs | 90 +++++++- .../LessonViewerViewModelTests.cs | 100 ++++++++ .../PlanningTabViewModelTests.cs | 4 +- LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../ViewModels/Groups/PlanningViewModels.cs | 216 +++++++++++++++++- .../Views/Groups/AlternativePathDialog.axaml | 57 +++++ .../Groups/AlternativePathDialog.axaml.cs | 21 ++ .../Views/Groups/LessonDialog.axaml | 34 +-- .../Views/Groups/LessonDialog.axaml.cs | 20 ++ .../Views/Groups/LessonViewerDialog.axaml | 90 ++++++++ .../Views/Groups/LessonViewerDialog.axaml.cs | 11 + .../Views/Groups/PlanningTabView.axaml | 2 + .../Views/Groups/PlanningTabView.axaml.cs | 11 + TODO.md | 46 +++- docs/Datenmodell.md | 26 ++- 22 files changed, 904 insertions(+), 33 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/LessonViewerViewModelTests.cs create mode 100644 LehrerApp.Desktop/Views/Groups/AlternativePathDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/AlternativePathDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/LessonViewerDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/LessonViewerDialog.axaml.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index a2e7b93..38f78a2 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -163,3 +163,10 @@ public interface IShorthandCodeRepository void Save(ShorthandCode code); void Delete(Guid id); } +public interface IAlternativeLessonPathRepository +{ + List GetAll(); + AlternativeLessonPath? GetById(Guid id); + void Save(AlternativeLessonPath path); + void Delete(Guid id); +} diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index c1996a3..e247859 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -79,11 +79,37 @@ public class LessonPhaseStep public string Activity { get; set; } = ""; public string Material { get; set; } = ""; public string Shorthand { get; set; } = ""; + /// + /// null = Hauptweg. Sonst Verweis auf einen benannten aus + /// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören — + /// Verlaufsplan-Editor und -Ansicht gruppieren danach. Bewusst kein Fork-Punkt-Bezug zum + /// Hauptweg: eine Alternative beginnt in der Anzeige unabhängig ab dem Stundenbeginn, nicht ab + /// einer gemeinsamen Verzweigungsstelle — für den schnellen Überblick ausreichend, siehe TODO.md. + /// + public Guid? AlternativePathId { get; set; } } public enum UnitStatus { Planned, Active, Completed } public enum LessonStatus { Planned, Conducted } +/// +/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei +/// Zeitmangel), dem einzelne -Zeilen über +/// zugeordnet werden. Wird direkt beim Zuweisen im +/// Verlaufsplan-Editor angelegt (kein separates Verwaltungs-UI in den Einstellungen bisher). +/// +public class AlternativeLessonPath +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = ""; + /// Freitext, wann/wofür dieser Ablauf gedacht ist, z.B. "Bei Zeitmangel, wenn Aufgabe 3 nicht + /// mehr passt." Absichtlich kein strukturiertes Bedingungsfeld (z.B. "wenn an Stelle X weniger + /// als Y Minuten übrig sind") — das setzt eine Live-Zeiterfassung während des Unterrichtens + /// voraus, die es noch nicht gibt (siehe Live-Unterrichtsmodus-Ideensammlung in TODO.md). + public string? Description { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + /// /// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr). /// ist das zuletzt berechnete Ergebnis; diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs index 6fe1dd7..3e26242 100644 --- a/LehrerApp.Data.Tests/LiteDbContextTests.cs +++ b/LehrerApp.Data.Tests/LiteDbContextTests.cs @@ -121,7 +121,7 @@ public sealed class LiteDbContextTests using var temp = new TempDatabase(); using var context = new LiteDbContext(temp.Path); - Assert.Equal(3, context.SchemaVersion); + Assert.Equal(4, context.SchemaVersion); } [Fact] @@ -132,7 +132,7 @@ public sealed class LiteDbContextTests using var second = new LiteDbContext(temp.Path); - Assert.Equal(3, second.SchemaVersion); + Assert.Equal(4, second.SchemaVersion); } [Fact] @@ -224,6 +224,60 @@ public sealed class LiteDbContextTests Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandTo")); } + [Fact] + public void MigrateLessonAlternativePaths_ErzeugtKatalogEintraegeUndVerweistDarauf() + { + using var temp = new TempDatabase(); + var lesson1Id = Guid.NewGuid(); + var lesson2Id = Guid.NewGuid(); + + using (var legacy = new LiteDatabase(temp.Path)) + { + legacy.GetCollection("lessons").Insert(new BsonDocument + { + ["_id"] = lesson1Id, + [nameof(Lesson.UnitId)] = Guid.NewGuid(), + [nameof(Lesson.GroupId)] = Guid.NewGuid(), + [nameof(Lesson.Phases)] = new BsonArray + { + new BsonDocument { ["AlternativePath"] = "Kurzversion" }, + new BsonDocument { ["AlternativePath"] = "" }, // Hauptweg, kein Katalogeintrag + new BsonDocument { }, // gar kein Feld, unangetastet + }, + }); + legacy.GetCollection("lessons").Insert(new BsonDocument + { + ["_id"] = lesson2Id, + [nameof(Lesson.UnitId)] = Guid.NewGuid(), + [nameof(Lesson.GroupId)] = Guid.NewGuid(), + [nameof(Lesson.Phases)] = new BsonArray + { + new BsonDocument { ["AlternativePath"] = "Kurzversion" }, // gleicher Name -> gleicher Katalogeintrag + }, + }); + } + + using (var context = new LiteDbContext(temp.Path)) + { + var catalog = context.AlternativeLessonPaths.FindAll().ToList(); + var entry = Assert.Single(catalog); + Assert.Equal("Kurzversion", entry.Name); + + var lesson1 = context.Lessons.FindById(lesson1Id)!; + Assert.Equal(entry.Id, lesson1.Phases[0].AlternativePathId); + Assert.Null(lesson1.Phases[1].AlternativePathId); + Assert.Null(lesson1.Phases[2].AlternativePathId); + + var lesson2 = context.Lessons.FindById(lesson2Id)!; + Assert.Equal(entry.Id, lesson2.Phases[0].AlternativePathId); // wiederverwendeter Katalogeintrag + } + + using var migrated = new LiteDatabase(temp.Path); + var rawLesson1 = migrated.GetCollection("lessons").FindById(lesson1Id); + var rawPhases = rawLesson1["Phases"].AsArray; + Assert.False(rawPhases[0].AsDocument.ContainsKey("AlternativePath")); + } + private sealed class TempDatabase : IDisposable { private readonly string _directory = System.IO.Path.Combine( diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 17b5501..c049415 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -488,4 +488,45 @@ public sealed class RepositoryTests Assert.Empty(repo.GetAll()); } + + // ── AlternativeLessonPathRepository ─────────────────────────────────────── + + [Fact] + public void AlternativeLessonPathRepository_Save_LehntDuplikatNamenAb() + { + using var db = NewInMemoryContext(); + var repo = new AlternativeLessonPathRepository(db); + repo.Save(new AlternativeLessonPath { Name = "Kurzversion" }); + + Assert.Throws(() => + repo.Save(new AlternativeLessonPath { Name = "kurzversion" })); + } + + [Fact] + public void AlternativeLessonPathRepository_GetById_FindetGespeichertenEintragMitBeschreibung() + { + using var db = NewInMemoryContext(); + var repo = new AlternativeLessonPathRepository(db); + var entry = new AlternativeLessonPath { Name = "Kurzversion", Description = "Bei Zeitmangel." }; + repo.Save(entry); + + var found = repo.GetById(entry.Id); + + Assert.NotNull(found); + Assert.Equal("Kurzversion", found!.Name); + Assert.Equal("Bei Zeitmangel.", found.Description); + } + + [Fact] + public void AlternativeLessonPathRepository_Delete_EntferntEintrag() + { + using var db = NewInMemoryContext(); + var repo = new AlternativeLessonPathRepository(db); + repo.Save(new AlternativeLessonPath { Name = "Kurzversion" }); + var id = repo.GetAll().Single().Id; + + repo.Delete(id); + + Assert.Empty(repo.GetAll()); + } } diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 11d1ca8..1aa8ffe 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -11,7 +11,7 @@ public class LiteDbContext : IDisposable /// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter /// ergänzt, statt bei jedem Start erneut /// (idempotent, aber unnötig) über alle Daten zu laufen. - private const int CurrentSchemaVersion = 3; + private const int CurrentSchemaVersion = 4; private readonly LiteDatabase _db; @@ -56,6 +56,7 @@ public class LiteDbContext : IDisposable public ILiteCollection Subjects => _db.GetCollection("subjects"); public ILiteCollection CompetencyDomains => _db.GetCollection("competency_domains"); public ILiteCollection ShorthandCodes => _db.GetCollection("shorthand_codes"); + public ILiteCollection AlternativeLessonPaths => _db.GetCollection("alternative_lesson_paths"); public void Checkpoint() => _db.Checkpoint(); @@ -94,6 +95,11 @@ public class LiteDbContext : IDisposable MigrateLessonShorthand(); version = 3; } + if (version < 4) + { + MigrateLessonAlternativePaths(); + version = 4; + } WriteSchemaVersion(version); } @@ -258,6 +264,46 @@ public class LiteDbContext : IDisposable } } + /// Führt das anfangs freie Textfeld für den alternativen Ablauf einer Phase + /// (AlternativePath, String) in einen Verweis auf einen Katalogeintrag + /// () über — pro bisher verwendetem, distinktem + /// Namen wird ein angelegt (bzw. ein gleichnamiger + /// wiederverwendet) und referenziert. Die neue Collection ist von dieser Migration selbst nicht + /// betroffen (keine Altstruktur), daher direkter Zugriff über die typisierte Collection. + private void MigrateLessonAlternativePaths() + { + var lessons = _db.GetCollection("lessons"); + var nameToId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var existing in AlternativeLessonPaths.FindAll()) nameToId[existing.Name] = existing.Id; + + foreach (var lesson in lessons.FindAll().ToList()) + { + if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue; + + var changed = false; + foreach (var phaseValue in phasesValue.AsArray) + { + if (phaseValue is not BsonDocument phase) continue; + if (!phase.TryGetValue("AlternativePath", out var oldVal) || !oldVal.IsString) continue; + + var name = oldVal.AsString.Trim(); + phase.Remove("AlternativePath"); + changed = true; + if (name.Length == 0) continue; + + if (!nameToId.TryGetValue(name, out var id)) + { + var entry = new AlternativeLessonPath { Name = name }; + AlternativeLessonPaths.Insert(entry); + id = entry.Id; + nameToId[name] = id; + } + phase[nameof(LessonPhaseStep.AlternativePathId)] = id; + } + if (changed) lessons.Update(lesson); + } + } + private void RemoveRedundantLegacyFields() { RemoveFields("exams", "Subject"); @@ -322,6 +368,7 @@ public class LiteDbContext : IDisposable CompetencyDomains.EnsureIndex(x => x.SubjectId); CompetencyDomains.EnsureIndex(x => x.GradeLevel); ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true); + AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); } public void Dispose() => _db.Dispose(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 72cd59b..d68c5dd 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -367,6 +367,25 @@ public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepositor public void Delete(Guid id) => db.ShorthandCodes.Delete(id); } +public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository +{ + public List GetAll() => db.AlternativeLessonPaths.FindAll().OrderBy(p => p.Name).ToList(); + public AlternativeLessonPath? GetById(Guid id) => db.AlternativeLessonPaths.FindById(id); + public void Save(AlternativeLessonPath p) + { + p.Name = p.Name.Trim(); + p.Description = string.IsNullOrWhiteSpace(p.Description) ? null : p.Description.Trim(); + if (p.Name.Length == 0) throw new ArgumentException("Der Name darf nicht leer sein."); + var duplicate = db.AlternativeLessonPaths.FindAll() + .FirstOrDefault(x => string.Equals(x.Name, p.Name, StringComparison.OrdinalIgnoreCase)); + if (duplicate is not null && duplicate.Id != p.Id) + throw new InvalidOperationException("Ein alternativer Ablauf mit diesem Namen existiert bereits."); + p.UpdatedAt = DateTime.UtcNow; + db.AlternativeLessonPaths.Upsert(p); + } + public void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id); +} + public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository { public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 7d811fe..d810ef3 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -198,6 +198,14 @@ public class FakeShorthandCodes(List all) : IShorthandCodeReposit public void Delete(Guid id) { } } +public class FakeAlternativeLessonPaths(List all) : IAlternativeLessonPathRepository +{ + public List GetAll() => all; + public AlternativeLessonPath? GetById(Guid id) => all.FirstOrDefault(p => p.Id == id); + public void Save(AlternativeLessonPath path) { all.RemoveAll(p => p.Id == path.Id); all.Add(path); } + public void Delete(Guid id) => all.RemoveAll(p => p.Id == id); +} + public class FakeReportGrades : IReportGradeRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs index 504f4c3..e749e6b 100644 --- a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs @@ -7,7 +7,8 @@ namespace LehrerApp.Desktop.Tests; public sealed class LessonDialogViewModelTests { private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null) => - new(new FakeLessons(), new FakeShorthandCodes([]), unitId, groupId, [], [], editing); + new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), + unitId, groupId, [], [], editing); [Fact] public void AddPhase_FuegtZeileMitStandardwertenHinzu() @@ -18,6 +19,7 @@ public sealed class LessonDialogViewModelTests Assert.Single(vm.Phases); Assert.Equal(5, vm.Phases[0].DurationMinutes); + Assert.False(vm.Phases[0].HasAlternativePath); } [Fact] @@ -68,8 +70,8 @@ public sealed class LessonDialogViewModelTests public void ShorthandSuggestions_KombiniertKatalogUndBisherigeStundenwerte() { var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]); - var vm = new LessonDialogViewModel(new FakeLessons(), codes, Guid.NewGuid(), Guid.NewGuid(), - [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen + var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]), + Guid.NewGuid(), Guid.NewGuid(), [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions); } @@ -130,7 +132,8 @@ public sealed class LessonDialogViewModelTests var unitId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var lessons = new FakeLessons(); - var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), unitId, groupId, [], [], null) + var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), + unitId, groupId, [], [], null) { Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45", }; @@ -153,4 +156,83 @@ public sealed class LessonDialogViewModelTests Assert.Equal("L->AB01", vm.Result.Phases[0].Shorthand); Assert.Single(lessons.GetByUnit(unitId)); } + + [Fact] + public void HasAlternativePath_AngehaktOeffnetZuweisungUndUebernimmtErgebnis() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + var path = new AlternativeLessonPath { Name = "Kurzversion" }; + vm.OnPickAlternativePath = _ => Task.FromResult(path); + vm.AddPhaseCommand.Execute(null); + + vm.Phases[0].HasAlternativePath = true; + + Assert.True(vm.Phases[0].HasAlternativePath); + Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName); + Assert.Equal(path.Id, vm.Phases[0].AlternativePathId); + } + + [Fact] + public void HasAlternativePath_AbbruchDerZuweisungLaesstEsUnangehakt() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.OnPickAlternativePath = _ => Task.FromResult(null); // abgebrochen + vm.AddPhaseCommand.Execute(null); + + vm.Phases[0].HasAlternativePath = true; + + Assert.False(vm.Phases[0].HasAlternativePath); + Assert.Null(vm.Phases[0].AlternativePathId); + } + + [Fact] + public void HasAlternativePath_AbwaehlenEntferntZuordnungOhneDialogZuOeffnen() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + var path = new AlternativeLessonPath { Name = "Kurzversion" }; + var dialogCalls = 0; + vm.OnPickAlternativePath = _ => { dialogCalls++; return Task.FromResult(path); }; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].HasAlternativePath = true; // zuweisen -> 1 Dialogaufruf + + vm.Phases[0].HasAlternativePath = false; // abwählen -> kein weiterer Dialogaufruf + + Assert.Equal(1, dialogCalls); + Assert.False(vm.Phases[0].HasAlternativePath); + Assert.Equal("", vm.Phases[0].AlternativePathName); + Assert.Null(vm.Phases[0].AlternativePathId); + } + + [Fact] + public void Save_UebernimmtZugewiesenenAlternativePfad() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + var path = new AlternativeLessonPath { Name = "Kurzversion" }; + vm.OnPickAlternativePath = _ => Task.FromResult(path); + vm.Topic = "Thema"; vm.DateText = "01.09.2025"; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].HasAlternativePath = true; + + vm.SaveCommand.Execute(null); + + Assert.Equal(path.Id, vm.Result!.Phases[0].AlternativePathId); + } + + [Fact] + public void AddPhaseInternal_LoestBestehendeZuordnungBeimBearbeitenAuf() + { + var path = new AlternativeLessonPath { Name = "Kurzversion" }; + var alternativePaths = new FakeAlternativeLessonPaths([path]); + var editing = new Lesson + { + Topic = "Thema", Date = new DateOnly(2025, 9, 1), + Phases = [new LessonPhaseStep { Name = "Einstieg", AlternativePathId = path.Id }], + }; + + var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths, + Guid.NewGuid(), Guid.NewGuid(), [], [], editing); + + Assert.True(vm.Phases[0].HasAlternativePath); + Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName); + } } diff --git a/LehrerApp.Desktop.Tests/LessonViewerViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonViewerViewModelTests.cs new file mode 100644 index 0000000..598b842 --- /dev/null +++ b/LehrerApp.Desktop.Tests/LessonViewerViewModelTests.cs @@ -0,0 +1,100 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class LessonViewerViewModelTests +{ + [Fact] + public void OhneAlternativenGibtEsGenauEineHauptwegGruppe() + { + var lesson = new Lesson + { + Topic = "Brechung", Date = new DateOnly(2025, 9, 1), StartTime = new TimeOnly(11, 45), + Phases = + [ + new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 5, Activity = "Begrüßung" }, + new LessonPhaseStep { Name = "Erarbeitung", DurationMinutes = 15, Activity = "AB01 bearbeiten" }, + ], + }; + + var vm = new LessonViewerViewModel(lesson, new FakeAlternativeLessonPaths([])); + + Assert.False(vm.HasAlternatives); + var group = Assert.Single(vm.PhaseGroups); + Assert.Equal("Hauptweg", group.Label); + Assert.True(group.IsMainPath); + Assert.Equal(2, group.Phases.Count); + Assert.Equal("ab 11:45", group.Phases[0].TimeDisplay); + Assert.Equal("ab 11:50", group.Phases[1].TimeDisplay); + } + + [Fact] + public void OhneBeginnBleibtDieUhrzeitanzeigeLeer() + { + var lesson = new Lesson + { + Topic = "Brechung", Date = new DateOnly(2025, 9, 1), + Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 5 }], + }; + + var vm = new LessonViewerViewModel(lesson, new FakeAlternativeLessonPaths([])); + + Assert.Equal("", vm.PhaseGroups[0].Phases[0].TimeDisplay); + Assert.Equal("–", vm.StartTimeDisplay); + } + + [Fact] + public void GruppiertPhasenNachAlternativePathUndHauptwegStehtImmerZuerst() + { + var kurzversion = new AlternativeLessonPath { Name = "Kurzversion", Description = "Bei Zeitmangel." }; + var alternativePaths = new FakeAlternativeLessonPaths([kurzversion]); + var lesson = new Lesson + { + Topic = "Brechung", Date = new DateOnly(2025, 9, 1), StartTime = new TimeOnly(12, 0), + Phases = + [ + new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 5, AlternativePathId = kurzversion.Id }, + new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 5 }, // Hauptweg, aber nach der Alternative angelegt + new LessonPhaseStep { Name = "Sicherung (kurz)", DurationMinutes = 5, AlternativePathId = kurzversion.Id }, + new LessonPhaseStep { Name = "Erarbeitung", DurationMinutes = 20 }, + ], + }; + + var vm = new LessonViewerViewModel(lesson, alternativePaths); + + Assert.True(vm.HasAlternatives); + Assert.Equal(2, vm.PhaseGroups.Count); + Assert.Equal("Hauptweg", vm.PhaseGroups[0].Label); // steht zuerst trotz Reihenfolge im Modell + Assert.True(vm.PhaseGroups[0].IsMainPath); + Assert.Null(vm.PhaseGroups[0].Description); + Assert.Equal(2, vm.PhaseGroups[0].Phases.Count); + Assert.Equal("Kurzversion", vm.PhaseGroups[1].Label); + Assert.False(vm.PhaseGroups[1].IsMainPath); + Assert.Equal("Bei Zeitmangel.", vm.PhaseGroups[1].Description); + Assert.Equal(2, vm.PhaseGroups[1].Phases.Count); + } + + [Fact] + public void JedeGruppeBerechnetIhreEigeneUhrzeitAbStundenbeginn() + { + var planB = new AlternativeLessonPath { Name = "Plan B" }; + var alternativePaths = new FakeAlternativeLessonPaths([planB]); + var lesson = new Lesson + { + Topic = "Brechung", Date = new DateOnly(2025, 9, 1), StartTime = new TimeOnly(12, 0), + Phases = + [ + new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 5 }, + new LessonPhaseStep { Name = "Vertiefung", DurationMinutes = 30, AlternativePathId = planB.Id }, + ], + }; + + var vm = new LessonViewerViewModel(lesson, alternativePaths); + + // Beide Gruppen starten unabhängig voneinander bei Stundenbeginn, nicht hintereinander. + Assert.Equal("ab 12:00", vm.PhaseGroups[0].Phases[0].TimeDisplay); + Assert.Equal("ab 12:00", vm.PhaseGroups[1].Phases[0].TimeDisplay); + } +} diff --git a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs index 206bc82..3b3a656 100644 --- a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs @@ -96,6 +96,7 @@ public class PlanningTabViewModelTests { var (vm, units, lessons, sourceGroupId) = BuildScenario(); var targetGroupId = Guid.NewGuid(); + var kurzversionId = Guid.NewGuid(); // Katalog-Referenz, bleibt beim Kopieren unverändert var unit = new Unit { @@ -111,7 +112,7 @@ public class PlanningTabViewModelTests UnitId = unit.Id, GroupId = sourceGroupId, Date = new DateOnly(2025, 9, 1), Topic = "Einführung", Status = LessonStatus.Conducted, Reflection = "lief gut", StartTime = new TimeOnly(11, 45), - Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "AB01", Shorthand = "L->AB01" }], + Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "AB01", Shorthand = "L->AB01", AlternativePathId = kurzversionId }], }; var l2 = new Lesson { @@ -144,6 +145,7 @@ public class PlanningTabViewModelTests Assert.Single(copiedLessons[0].Phases); Assert.Equal("Einstieg", copiedLessons[0].Phases[0].Name); Assert.Equal("L->AB01", copiedLessons[0].Phases[0].Shorthand); + Assert.Equal(kurzversionId, copiedLessons[0].Phases[0].AlternativePathId); Assert.NotEqual(l1.Phases[0].Id, copiedLessons[0].Phases[0].Id); // frische Id, keine geteilte Referenz // Ursprüngliche Einheit/Stunden bleiben unverändert in der Quellgruppe. diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 381c96f..958fb35 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -128,6 +128,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 81433d0..5cf55c5 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -41,7 +41,9 @@ public partial class PlanningTabViewModel : ObservableObject public ObservableCollection Lessons { get; } = []; /// Aus den Material-/Kurzsymbol-Werten aller bereits vorhandenen Stunden-Phasen der Gruppe - /// zusammengestellt (4.2.2 Autovervollständigung im Verlaufsplan-Editor). + /// zusammengestellt (4.2.2 Autovervollständigung im Verlaufsplan-Editor). Alternative Abläufe + /// kommen seit dem Katalog-Redesign nicht mehr aus der Stundenhistorie, sondern direkt aus + /// IAlternativeLessonPathRepository (siehe LessonDialogViewModel). public List KnownMaterials { get; private set; } = []; public List KnownShorthands { get; private set; } = []; @@ -53,6 +55,7 @@ public partial class PlanningTabViewModel : ObservableObject public Func, List, Task>? OnEditLesson { get; set; } public Func>? OnConfirmDeleteLesson { get; set; } public Func>? OnPickMoveTarget { get; set; } + public Func? OnShowLesson { get; set; } public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, ISubjectRepository subjects, @@ -120,6 +123,7 @@ public partial class PlanningTabViewModel : ObservableObject partial void OnSelectedLessonChanged(LessonSummary? value) { + ShowLessonCommand.NotifyCanExecuteChanged(); EditLessonCommand.NotifyCanExecuteChanged(); DeleteLessonCommand.NotifyCanExecuteChanged(); MoveLessonCommand.NotifyCanExecuteChanged(); @@ -209,6 +213,7 @@ public partial class PlanningTabViewModel : ObservableObject Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, + AlternativePathId = p.AlternativePathId, })], Homework = lesson.Homework, Reflection = null, @@ -233,6 +238,16 @@ public partial class PlanningTabViewModel : ObservableObject if (await OnEditLesson(SelectedLesson.Model, KnownMaterials, KnownShorthands)) LoadUnits(); } + /// Schreibgeschützte Anzeige des Verlaufsplans für den Einsatz im Unterricht (kein + /// Bearbeitungsrisiko). Bewusst ohne Live-Anpassung (Verlängern/Verschieben während des + /// Haltens) — das gehört zur zurückgestellten Live-Unterrichtsmodus-Ideensammlung (TODO.md). + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] + private async Task ShowLesson() + { + if (OnShowLesson is null || SelectedLesson is null) return; + await OnShowLesson(SelectedLesson.Model); + } + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task DeleteLesson() { @@ -542,6 +557,7 @@ public partial class UnitDialogViewModel : ObservableObject public partial class LessonDialogViewModel : ObservableObject { private readonly ILessonRepository _lessons; + private readonly IAlternativeLessonPathRepository _alternativePaths; private readonly Guid _unitId; private readonly Guid _groupId; private readonly Lesson? _editingLesson; @@ -563,15 +579,21 @@ public partial class LessonDialogViewModel : ObservableObject public string[] ShorthandSuggestions { get; } public ObservableCollection Phases { get; } = []; + /// Vom Code-Behind gesetzt (Fenster als Owner für den Zuweisen-Dialog): fragt nach dem + /// alternativen Ablauf, dem eine Phase zugeordnet werden soll (Auswahl oder Neuanlage + /// per Combobox-Dialog). null zurückgegeben = abgebrochen. + public Func>? OnPickAlternativePath { get; set; } + public Lesson? Result { get; private set; } public string DialogTitle => _editingLesson is null ? "Neue Stunde anlegen" : "Stunde bearbeiten"; public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern"; public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes, - Guid unitId, Guid groupId, List materialSuggestions, List shorthandHistorySuggestions, - Lesson? editingLesson) + IAlternativeLessonPathRepository alternativePaths, Guid unitId, Guid groupId, + List materialSuggestions, List shorthandHistorySuggestions, Lesson? editingLesson) { - _lessons = lessons; _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson; + _lessons = lessons; _alternativePaths = alternativePaths; + _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson; MaterialSuggestions = [.. materialSuggestions]; // Vorschläge kommen sowohl aus dem gepflegten Kürzel-Katalog (Einstellungen) als auch aus @@ -620,9 +642,19 @@ public partial class LessonDialogViewModel : ObservableObject item.OnRemove = RemovePhase; item.OnMoveUp = MovePhaseUp; item.OnMoveDown = MovePhaseDown; + item.OnAssignAlternativePath = AssignAlternativePath; + if (source?.AlternativePathId is Guid pathId) + item.SetAlternativePath(_alternativePaths.GetById(pathId)); Phases.Add(item); } + private async Task AssignAlternativePath(PhaseStepEditItem item) + { + if (OnPickAlternativePath is null) { item.SetAlternativePath(null); return; } + var picked = await OnPickAlternativePath(item.AlternativePathId); + item.SetAlternativePath(picked); + } + private void RemovePhase(PhaseStepEditItem item) { Phases.Remove(item); RecomputeTimes(); } private void MovePhaseUp(PhaseStepEditItem item) @@ -701,6 +733,9 @@ public partial class LessonDialogViewModel : ObservableObject public partial class PhaseStepEditItem : ObservableObject { + private static readonly string[] AlternativePathPalette = + ["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27"]; + [ObservableProperty] private string _name = ""; [ObservableProperty] private int _durationMinutes = 5; [ObservableProperty] private string _activity = ""; @@ -708,13 +743,53 @@ public partial class PhaseStepEditItem : ObservableObject [ObservableProperty] private string _shorthand = ""; [ObservableProperty] private string _computedTimeDisplay = ""; + /// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog + /// (); checked→unchecked entfernt die Zuordnung. + /// Änderungen, die von selbst kommen, lösen das nicht erneut aus. + [ObservableProperty] private bool _hasAlternativePath; + [ObservableProperty] private string _alternativePathName = ""; + [ObservableProperty] private string _alternativePathColorHex = "#9E9E9E"; + + private bool _suppressAlternativePathToggle; + + public Guid? AlternativePathId { get; private set; } + public Action? OnChanged { get; set; } public Action? OnRemove { get; set; } public Action? OnMoveUp { get; set; } public Action? OnMoveDown { get; set; } + public Func? OnAssignAlternativePath { get; set; } partial void OnDurationMinutesChanged(int value) => OnChanged?.Invoke(); + partial void OnHasAlternativePathChanged(bool value) + { + if (_suppressAlternativePathToggle) return; + if (value) _ = OnAssignAlternativePath?.Invoke(this); + else SetAlternativePath(null); + } + + /// Wird sowohl beim Laden einer bestehenden Zuordnung als auch nach dem Zuweisen-Dialog + /// aufgerufen (auch mit null bei Abbruch/Entfernen) — setzt Id/Anzeigename/Farbe konsistent + /// und unterdrückt dabei das erneute Öffnen des Dialogs über . + public void SetAlternativePath(AlternativeLessonPath? path) + { + _suppressAlternativePathToggle = true; + AlternativePathId = path?.Id; + AlternativePathName = path?.Name ?? ""; + AlternativePathColorHex = path is null ? "#9E9E9E" : ColorFor(path.Name); + HasAlternativePath = path is not null; + _suppressAlternativePathToggle = false; + OnChanged?.Invoke(); + } + + private static string ColorFor(string name) + { + var hash = 0; + foreach (var c in name) hash = hash * 31 + c; + return AlternativePathPalette[Math.Abs(hash) % AlternativePathPalette.Length]; + } + [RelayCommand] private void Remove() => OnRemove?.Invoke(this); [RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this); [RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this); @@ -726,9 +801,61 @@ public partial class PhaseStepEditItem : ObservableObject Activity = Activity.Trim(), Material = Material.Trim(), Shorthand = Shorthand.Trim(), + AlternativePathId = AlternativePathId, }; } +// ── Dialog: Alternativen Ablauf zuweisen/anlegen (4.2.2 Nachtrag) ──────────── + +public partial class AlternativePathDialogViewModel : ObservableObject +{ + private readonly IAlternativeLessonPathRepository _repo; + + [ObservableProperty] private AlternativeLessonPath? _selectedPath; + [ObservableProperty] private string _newName = ""; + [ObservableProperty] private string _newDescription = ""; + [ObservableProperty] private string _newNameError = ""; + [ObservableProperty] private string _selectionError = ""; + + public ObservableCollection Available { get; } = []; + public AlternativeLessonPath? Result { get; private set; } + + public AlternativePathDialogViewModel(IAlternativeLessonPathRepository repo, Guid? currentId) + { + _repo = repo; + foreach (var p in repo.GetAll()) Available.Add(p); + if (currentId is Guid id) SelectedPath = Available.FirstOrDefault(p => p.Id == id); + } + + [RelayCommand] + private void CreateNew() + { + NewNameError = ""; + if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; } + + var entry = new AlternativeLessonPath + { + Name = NewName.Trim(), + Description = string.IsNullOrWhiteSpace(NewDescription) ? null : NewDescription.Trim(), + }; + try { _repo.Save(entry); } + catch (InvalidOperationException ex) { NewNameError = ex.Message; return; } + catch (ArgumentException ex) { NewNameError = ex.Message; return; } + + Available.Add(entry); + SelectedPath = entry; + NewName = ""; NewDescription = ""; + } + + [RelayCommand] + private void Confirm() + { + SelectionError = ""; + if (SelectedPath is null) { SelectionError = "Bitte einen Ablauf auswählen oder neu anlegen."; return; } + Result = SelectedPath; + } +} + // ── Dialog: Stunde verschieben (4.2.4) ──────────────────────────────────────── public partial class MoveLessonDialogViewModel : ObservableObject @@ -794,3 +921,84 @@ public partial class CopyUnitDialogViewModel : ObservableObject Result = new CopyUnitTarget(SelectedGroup!.Id, anchor); } } + +// ── Verlaufsplan-Ansicht (schreibgeschützt, für den Unterrichtseinsatz) ────── + +/// Eine Phasen-Zeile in der schreibgeschützten Ansicht — reine Anzeige, keine Bearbeitung. +public record PhaseViewItem(string Name, int DurationMinutes, string TimeDisplay, + string Activity, string Material, string Shorthand); + +/// Eine Gruppe von Phasen mit demselben +/// ("Hauptweg" bei null). Jede Gruppe bekommt ihre eigene kumulierte Zeitberechnung ab +/// Lesson.StartTime — eine Alternative zeigt also "so würde die Uhr laufen, wenn man diesen Weg +/// von Stundenbeginn an nimmt", nicht ab einer gemeinsamen Verzweigungsstelle im Hauptweg. +public record PhaseGroupViewItem(string Label, bool IsMainPath, string? Description, List Phases); + +public class LessonViewerViewModel +{ + private const string MainPathLabel = "Hauptweg"; + + public string DateDisplay { get; } + public string Topic { get; } + public string StatusLabel { get; } + public string StartTimeDisplay { get; } + public string? Homework { get; } + public string? Reflection { get; } + public List PhaseGroups { get; } + public bool HasAlternatives { get; } + + public LessonViewerViewModel(Lesson lesson, IAlternativeLessonPathRepository alternativePaths) + { + DateDisplay = lesson.Date.ToString("dd.MM.yyyy"); + Topic = lesson.Topic; + StatusLabel = LessonStatusDisplay.ToName(lesson.Status); + StartTimeDisplay = lesson.StartTime?.ToString("HH:mm") ?? "–"; + Homework = lesson.Homework; + Reflection = lesson.Reflection; + + var order = new List(); + var descriptions = new Dictionary(); + var byLabel = new Dictionary>(); + foreach (var p in lesson.Phases) + { + string label; string? description = null; + if (p.AlternativePathId is Guid pathId) + { + var path = alternativePaths.GetById(pathId); + label = path?.Name ?? "Unbekannter Ablauf"; + description = path?.Description; + } + else label = MainPathLabel; + + if (!byLabel.TryGetValue(label, out var list)) + { + list = []; + byLabel[label] = list; + descriptions[label] = description; + order.Add(label); + } + list.Add(p); + } + + // Hauptweg immer zuerst, unabhängig davon, in welcher Reihenfolge Phasen angelegt wurden. + var orderedLabels = order.OrderBy(l => l == MainPathLabel ? 0 : 1).ToList(); + PhaseGroups = orderedLabels + .Select(label => new PhaseGroupViewItem( + label, label == MainPathLabel, descriptions[label], BuildPhaseViewItems(byLabel[label], lesson.StartTime))) + .ToList(); + HasAlternatives = PhaseGroups.Count > 1; + } + + private static List BuildPhaseViewItems(List steps, TimeOnly? startTime) + { + var cursor = startTime; + var items = new List(); + foreach (var p in steps) + { + var timeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : ""; + items.Add(new PhaseViewItem(p.Name, p.DurationMinutes, timeDisplay, p.Activity, p.Material, p.Shorthand)); + if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes); + } + return items; + } +} diff --git a/LehrerApp.Desktop/Views/Groups/AlternativePathDialog.axaml b/LehrerApp.Desktop/Views/Groups/AlternativePathDialog.axaml new file mode 100644 index 0000000..371d7cb --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/AlternativePathDialog.axaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +