Stundenverlaufsplan: Viewer und Alternativpfad-Katalog (Kapitel 4.2 Nachtrag)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 01:53:12 +02:00
co-authored by Claude Sonnet 5
parent de6ea001e7
commit ee1a47641e
22 changed files with 904 additions and 33 deletions
@@ -163,3 +163,10 @@ public interface IShorthandCodeRepository
void Save(ShorthandCode code); void Save(ShorthandCode code);
void Delete(Guid id); void Delete(Guid id);
} }
public interface IAlternativeLessonPathRepository
{
List<AlternativeLessonPath> GetAll();
AlternativeLessonPath? GetById(Guid id);
void Save(AlternativeLessonPath path);
void Delete(Guid id);
}
+26
View File
@@ -79,11 +79,37 @@ public class LessonPhaseStep
public string Activity { get; set; } = ""; public string Activity { get; set; } = "";
public string Material { get; set; } = ""; public string Material { get; set; } = "";
public string Shorthand { get; set; } = ""; public string Shorthand { get; set; } = "";
/// <summary>
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> 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.
/// </summary>
public Guid? AlternativePathId { get; set; }
} }
public enum UnitStatus { Planned, Active, Completed } public enum UnitStatus { Planned, Active, Completed }
public enum LessonStatus { Planned, Conducted } public enum LessonStatus { Planned, Conducted }
/// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
/// Zeitmangel), dem einzelne <see cref="LessonPhaseStep"/>-Zeilen über
/// <see cref="LessonPhaseStep.AlternativePathId"/> zugeordnet werden. Wird direkt beim Zuweisen im
/// Verlaufsplan-Editor angelegt (kein separates Verwaltungs-UI in den Einstellungen bisher).
/// </summary>
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;
}
/// <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"/>
+56 -2
View File
@@ -121,7 +121,7 @@ public sealed class LiteDbContextTests
using var temp = new TempDatabase(); using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path); using var context = new LiteDbContext(temp.Path);
Assert.Equal(3, context.SchemaVersion); Assert.Equal(4, context.SchemaVersion);
} }
[Fact] [Fact]
@@ -132,7 +132,7 @@ public sealed class LiteDbContextTests
using var second = new LiteDbContext(temp.Path); using var second = new LiteDbContext(temp.Path);
Assert.Equal(3, second.SchemaVersion); Assert.Equal(4, second.SchemaVersion);
} }
[Fact] [Fact]
@@ -224,6 +224,60 @@ public sealed class LiteDbContextTests
Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandTo")); 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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("lessons").FindById(lesson1Id);
var rawPhases = rawLesson1["Phases"].AsArray;
Assert.False(rawPhases[0].AsDocument.ContainsKey("AlternativePath"));
}
private sealed class TempDatabase : IDisposable private sealed class TempDatabase : IDisposable
{ {
private readonly string _directory = System.IO.Path.Combine( private readonly string _directory = System.IO.Path.Combine(
+41
View File
@@ -488,4 +488,45 @@ public sealed class RepositoryTests
Assert.Empty(repo.GetAll()); 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<InvalidOperationException>(() =>
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());
}
} }
+48 -1
View File
@@ -11,7 +11,7 @@ public class LiteDbContext : IDisposable
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter /// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
/// <see cref="RunVersionedMigrations"/> ergänzt, statt bei jedem Start erneut /// <see cref="RunVersionedMigrations"/> ergänzt, statt bei jedem Start erneut
/// (idempotent, aber unnötig) über alle Daten zu laufen. /// (idempotent, aber unnötig) über alle Daten zu laufen.
private const int CurrentSchemaVersion = 3; private const int CurrentSchemaVersion = 4;
private readonly LiteDatabase _db; private readonly LiteDatabase _db;
@@ -56,6 +56,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects"); public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects");
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains"); public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes"); public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
public void Checkpoint() => _db.Checkpoint(); public void Checkpoint() => _db.Checkpoint();
@@ -94,6 +95,11 @@ public class LiteDbContext : IDisposable
MigrateLessonShorthand(); MigrateLessonShorthand();
version = 3; version = 3;
} }
if (version < 4)
{
MigrateLessonAlternativePaths();
version = 4;
}
WriteSchemaVersion(version); WriteSchemaVersion(version);
} }
@@ -258,6 +264,46 @@ public class LiteDbContext : IDisposable
} }
} }
/// Führt das anfangs freie Textfeld für den alternativen Ablauf einer Phase
/// (<c>AlternativePath</c>, String) in einen Verweis auf einen Katalogeintrag
/// (<see cref="LessonPhaseStep.AlternativePathId"/>) über — pro bisher verwendetem, distinktem
/// Namen wird ein <see cref="AlternativeLessonPath"/> 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<BsonDocument>("lessons");
var nameToId = new Dictionary<string, Guid>(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() private void RemoveRedundantLegacyFields()
{ {
RemoveFields("exams", "Subject"); RemoveFields("exams", "Subject");
@@ -322,6 +368,7 @@ public class LiteDbContext : IDisposable
CompetencyDomains.EnsureIndex(x => x.SubjectId); CompetencyDomains.EnsureIndex(x => x.SubjectId);
CompetencyDomains.EnsureIndex(x => x.GradeLevel); CompetencyDomains.EnsureIndex(x => x.GradeLevel);
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true); 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(); public void Dispose() => _db.Dispose();
@@ -367,6 +367,25 @@ public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepositor
public void Delete(Guid id) => db.ShorthandCodes.Delete(id); public void Delete(Guid id) => db.ShorthandCodes.Delete(id);
} }
public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLessonPathRepository
{
public List<AlternativeLessonPath> 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 class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{ {
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+8
View File
@@ -198,6 +198,14 @@ public class FakeShorthandCodes(List<ShorthandCode> all) : IShorthandCodeReposit
public void Delete(Guid id) { } public void Delete(Guid id) { }
} }
public class FakeAlternativeLessonPaths(List<AlternativeLessonPath> all) : IAlternativeLessonPathRepository
{
public List<AlternativeLessonPath> 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 public class FakeReportGrades : IReportGradeRepository
{ {
private readonly List<ReportGrade> _all = []; private readonly List<ReportGrade> _all = [];
@@ -7,7 +7,8 @@ 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 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] [Fact]
public void AddPhase_FuegtZeileMitStandardwertenHinzu() public void AddPhase_FuegtZeileMitStandardwertenHinzu()
@@ -18,6 +19,7 @@ public sealed class LessonDialogViewModelTests
Assert.Single(vm.Phases); Assert.Single(vm.Phases);
Assert.Equal(5, vm.Phases[0].DurationMinutes); Assert.Equal(5, vm.Phases[0].DurationMinutes);
Assert.False(vm.Phases[0].HasAlternativePath);
} }
[Fact] [Fact]
@@ -68,8 +70,8 @@ public sealed class LessonDialogViewModelTests
public void ShorthandSuggestions_KombiniertKatalogUndBisherigeStundenwerte() public void ShorthandSuggestions_KombiniertKatalogUndBisherigeStundenwerte()
{ {
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, Guid.NewGuid(), Guid.NewGuid(), var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]),
[], ["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);
} }
@@ -130,7 +132,8 @@ public sealed class LessonDialogViewModelTests
var unitId = Guid.NewGuid(); var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid(); var groupId = Guid.NewGuid();
var lessons = new FakeLessons(); 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", 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.Equal("L->AB01", vm.Result.Phases[0].Shorthand);
Assert.Single(lessons.GetByUnit(unitId)); 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<AlternativeLessonPath?>(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<AlternativeLessonPath?>(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<AlternativeLessonPath?>(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<AlternativeLessonPath?>(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);
}
} }
@@ -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);
}
}
@@ -96,6 +96,7 @@ public class PlanningTabViewModelTests
{ {
var (vm, units, lessons, sourceGroupId) = BuildScenario(); var (vm, units, lessons, sourceGroupId) = BuildScenario();
var targetGroupId = Guid.NewGuid(); var targetGroupId = Guid.NewGuid();
var kurzversionId = Guid.NewGuid(); // Katalog-Referenz, bleibt beim Kopieren unverändert
var unit = new Unit var unit = new Unit
{ {
@@ -111,7 +112,7 @@ public class PlanningTabViewModelTests
UnitId = unit.Id, GroupId = sourceGroupId, Date = new DateOnly(2025, 9, 1), UnitId = unit.Id, GroupId = sourceGroupId, Date = new DateOnly(2025, 9, 1),
Topic = "Einführung", Status = LessonStatus.Conducted, Reflection = "lief gut", Topic = "Einführung", Status = LessonStatus.Conducted, Reflection = "lief gut",
StartTime = new TimeOnly(11, 45), 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 var l2 = new Lesson
{ {
@@ -144,6 +145,7 @@ public class PlanningTabViewModelTests
Assert.Single(copiedLessons[0].Phases); Assert.Single(copiedLessons[0].Phases);
Assert.Equal("Einstieg", copiedLessons[0].Phases[0].Name); Assert.Equal("Einstieg", copiedLessons[0].Phases[0].Name);
Assert.Equal("L->AB01", copiedLessons[0].Phases[0].Shorthand); 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 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. // Ursprüngliche Einheit/Stunden bleiben unverändert in der Quellgruppe.
+1
View File
@@ -128,6 +128,7 @@ public static class AppBootstrapper
services.AddSingleton<ISubjectRepository, SubjectRepository>(); services.AddSingleton<ISubjectRepository, SubjectRepository>();
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>(); services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>(); services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>();
services.AddSingleton<IAlternativeLessonPathRepository, AlternativeLessonPathRepository>();
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>(); services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
@@ -41,7 +41,9 @@ public partial class PlanningTabViewModel : ObservableObject
public ObservableCollection<LessonSummary> Lessons { get; } = []; public ObservableCollection<LessonSummary> Lessons { get; } = [];
/// Aus den Material-/Kurzsymbol-Werten aller bereits vorhandenen Stunden-Phasen der Gruppe /// 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<string> KnownMaterials { get; private set; } = []; public List<string> KnownMaterials { get; private set; } = [];
public List<string> KnownShorthands { get; private set; } = []; public List<string> KnownShorthands { get; private set; } = [];
@@ -53,6 +55,7 @@ public partial class PlanningTabViewModel : ObservableObject
public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; } public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; }
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 PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects, IGroupRepository groups, ISubjectRepository subjects,
@@ -120,6 +123,7 @@ public partial class PlanningTabViewModel : ObservableObject
partial void OnSelectedLessonChanged(LessonSummary? value) partial void OnSelectedLessonChanged(LessonSummary? value)
{ {
ShowLessonCommand.NotifyCanExecuteChanged();
EditLessonCommand.NotifyCanExecuteChanged(); EditLessonCommand.NotifyCanExecuteChanged();
DeleteLessonCommand.NotifyCanExecuteChanged(); DeleteLessonCommand.NotifyCanExecuteChanged();
MoveLessonCommand.NotifyCanExecuteChanged(); MoveLessonCommand.NotifyCanExecuteChanged();
@@ -209,6 +213,7 @@ public partial class PlanningTabViewModel : ObservableObject
Activity = p.Activity, Activity = p.Activity,
Material = p.Material, Material = p.Material,
Shorthand = p.Shorthand, Shorthand = p.Shorthand,
AlternativePathId = p.AlternativePathId,
})], })],
Homework = lesson.Homework, Homework = lesson.Homework,
Reflection = null, Reflection = null,
@@ -233,6 +238,16 @@ public partial class PlanningTabViewModel : ObservableObject
if (await OnEditLesson(SelectedLesson.Model, KnownMaterials, KnownShorthands)) LoadUnits(); 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))] [RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private async Task DeleteLesson() private async Task DeleteLesson()
{ {
@@ -542,6 +557,7 @@ public partial class UnitDialogViewModel : ObservableObject
public partial class LessonDialogViewModel : ObservableObject public partial class LessonDialogViewModel : ObservableObject
{ {
private readonly ILessonRepository _lessons; private readonly ILessonRepository _lessons;
private readonly IAlternativeLessonPathRepository _alternativePaths;
private readonly Guid _unitId; private readonly Guid _unitId;
private readonly Guid _groupId; private readonly Guid _groupId;
private readonly Lesson? _editingLesson; private readonly Lesson? _editingLesson;
@@ -563,15 +579,21 @@ public partial class LessonDialogViewModel : ObservableObject
public string[] ShorthandSuggestions { get; } public string[] ShorthandSuggestions { get; }
public ObservableCollection<PhaseStepEditItem> Phases { get; } = []; public ObservableCollection<PhaseStepEditItem> 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<Guid?, Task<AlternativeLessonPath?>>? OnPickAlternativePath { get; set; }
public Lesson? Result { get; private set; } public Lesson? Result { get; private set; }
public string DialogTitle => _editingLesson is null ? "Neue Stunde anlegen" : "Stunde bearbeiten"; public string DialogTitle => _editingLesson is null ? "Neue Stunde anlegen" : "Stunde bearbeiten";
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,
Guid unitId, Guid groupId, List<string> materialSuggestions, List<string> shorthandHistorySuggestions, IAlternativeLessonPathRepository alternativePaths, Guid unitId, Guid groupId,
Lesson? editingLesson) List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
{ {
_lessons = lessons; _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson; _lessons = lessons; _alternativePaths = alternativePaths;
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
MaterialSuggestions = [.. materialSuggestions]; MaterialSuggestions = [.. materialSuggestions];
// Vorschläge kommen sowohl aus dem gepflegten Kürzel-Katalog (Einstellungen) als auch aus // 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.OnRemove = RemovePhase;
item.OnMoveUp = MovePhaseUp; item.OnMoveUp = MovePhaseUp;
item.OnMoveDown = MovePhaseDown; item.OnMoveDown = MovePhaseDown;
item.OnAssignAlternativePath = AssignAlternativePath;
if (source?.AlternativePathId is Guid pathId)
item.SetAlternativePath(_alternativePaths.GetById(pathId));
Phases.Add(item); 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 RemovePhase(PhaseStepEditItem item) { Phases.Remove(item); RecomputeTimes(); }
private void MovePhaseUp(PhaseStepEditItem item) private void MovePhaseUp(PhaseStepEditItem item)
@@ -701,6 +733,9 @@ public partial class LessonDialogViewModel : ObservableObject
public partial class PhaseStepEditItem : ObservableObject public partial class PhaseStepEditItem : ObservableObject
{ {
private static readonly string[] AlternativePathPalette =
["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27"];
[ObservableProperty] private string _name = ""; [ObservableProperty] private string _name = "";
[ObservableProperty] private int _durationMinutes = 5; [ObservableProperty] private int _durationMinutes = 5;
[ObservableProperty] private string _activity = ""; [ObservableProperty] private string _activity = "";
@@ -708,13 +743,53 @@ public partial class PhaseStepEditItem : ObservableObject
[ObservableProperty] private string _shorthand = ""; [ObservableProperty] private string _shorthand = "";
[ObservableProperty] private string _computedTimeDisplay = ""; [ObservableProperty] private string _computedTimeDisplay = "";
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
/// Änderungen, die von <see cref="SetAlternativePath"/> 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? OnChanged { get; set; }
public Action<PhaseStepEditItem>? OnRemove { get; set; } public Action<PhaseStepEditItem>? OnRemove { get; set; }
public Action<PhaseStepEditItem>? OnMoveUp { get; set; } public Action<PhaseStepEditItem>? OnMoveUp { get; set; }
public Action<PhaseStepEditItem>? OnMoveDown { get; set; } public Action<PhaseStepEditItem>? OnMoveDown { get; set; }
public Func<PhaseStepEditItem, Task>? OnAssignAlternativePath { get; set; }
partial void OnDurationMinutesChanged(int value) => OnChanged?.Invoke(); 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 <see cref="OnHasAlternativePathChanged"/>.
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 Remove() => OnRemove?.Invoke(this);
[RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this); [RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this);
[RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this); [RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this);
@@ -726,9 +801,61 @@ public partial class PhaseStepEditItem : ObservableObject
Activity = Activity.Trim(), Activity = Activity.Trim(),
Material = Material.Trim(), Material = Material.Trim(),
Shorthand = Shorthand.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<AlternativeLessonPath> 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) ──────────────────────────────────────── // ── Dialog: Stunde verschieben (4.2.4) ────────────────────────────────────────
public partial class MoveLessonDialogViewModel : ObservableObject public partial class MoveLessonDialogViewModel : ObservableObject
@@ -794,3 +921,84 @@ public partial class CopyUnitDialogViewModel : ObservableObject
Result = new CopyUnitTarget(SelectedGroup!.Id, anchor); 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 <see cref="LessonPhaseStep.AlternativePathId"/>
/// ("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<PhaseViewItem> 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<PhaseGroupViewItem> 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<string>();
var descriptions = new Dictionary<string, string?>();
var byLabel = new Dictionary<string, List<LessonPhaseStep>>();
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<PhaseViewItem> BuildPhaseViewItems(List<LessonPhaseStep> steps, TimeOnly? startTime)
{
var cursor = startTime;
var items = new List<PhaseViewItem>();
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;
}
}
@@ -0,0 +1,57 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
x:Class="LehrerApp.Desktop.Views.Groups.AlternativePathDialog"
x:DataType="vm:AlternativePathDialogViewModel"
Title="Alternativer Ablauf"
Width="440" Height="500" MinWidth="380" MinHeight="380"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14">
<TextBlock Text="Alternativer Ablauf" Classes="dialogtitle"/>
<TextBlock Text="Diese Phase gehört zu einem alternativen Unterrichtsverlauf, z.B. einer Kurzversion bei Zeitmangel."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<StackPanel Spacing="4">
<TextBlock Text="Vorhandene Abläufe" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Available}" SelectedItem="{Binding SelectedPath}"
HorizontalAlignment="Stretch" PlaceholderText="Ablauf auswählen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:AlternativeLessonPath">
<StackPanel Margin="0,2">
<TextBlock Text="{Binding Name}"/>
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.6" TextWrapping="Wrap"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{Binding SelectionError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SelectionError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Separator Margin="0,4"/>
<StackPanel Spacing="10">
<TextBlock Text="Neuen Ablauf anlegen" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewName}" PlaceholderText="Name, z.B. Kurzversion"/>
<TextBlock Text="{Binding NewNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding NewNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TextBox Text="{Binding NewDescription}" AcceptsReturn="True" Height="64" TextWrapping="Wrap"
PlaceholderText="Wann nimmt man diesen Weg? z.B. 'Bei Zeitmangel, wenn Aufgabe 3 nicht mehr passt.'"/>
<Button Content="Anlegen und auswählen" Command="{Binding CreateNewCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</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="Übernehmen" HorizontalAlignment="Stretch" Click="OnConfirm"/>
</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 AlternativePathDialog : Window
{
public AlternativePathDialog() => InitializeComponent();
private void OnConfirm(object? s, RoutedEventArgs e)
{
if (DataContext is AlternativePathDialogViewModel vm && vm.ConfirmCommand.CanExecute(null))
{
vm.ConfirmCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -4,7 +4,7 @@
x:Class="LehrerApp.Desktop.Views.Groups.LessonDialog" x:Class="LehrerApp.Desktop.Views.Groups.LessonDialog"
x:DataType="vm:LessonDialogViewModel" x:DataType="vm:LessonDialogViewModel"
Title="{Binding DialogTitle}" Title="{Binding DialogTitle}"
Width="960" Height="760" MinWidth="720" MinHeight="480" Width="1000" Height="760" MinWidth="780" MinHeight="480"
CanResize="True" WindowStartupLocation="CenterOwner"> CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
@@ -56,12 +56,14 @@
</Grid> </Grid>
<!-- Tabellenkopf --> <!-- Tabellenkopf -->
<Grid ColumnDefinitions="140,130,*,110,100,26,26,26" Margin="4,0,0,0"> <Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26" Margin="4,0,0,0">
<TextBlock Grid.Column="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/> <TextBlock Grid.Column="0" Text="Phase" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Dauer / Zeit" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/> <TextBlock Grid.Column="1" Text="Pfad" FontSize="11" FontWeight="SemiBold" Opacity="0.6"
<TextBlock Grid.Column="2" Text="Tätigkeit" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/> ToolTip.Tip="Ankreuzen, wenn diese Phase zu einem alternativen Ablauf gehört (z.B. Kurzversion bei Zeitnot) — öffnet die Zuweisung. Phasen mit demselben Ablauf werden im Verlaufsplan-Viewer gruppiert."/>
<TextBlock Grid.Column="3" Text="Material" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/> <TextBlock Grid.Column="2" Text="Dauer / Zeit" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Kurzsymbol" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/> <TextBlock Grid.Column="3" Text="Tätigkeit" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Material" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Kurzsymbol" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
</Grid> </Grid>
<Separator/> <Separator/>
@@ -71,34 +73,38 @@
<DataTemplate x:DataType="vm:PhaseStepEditItem"> <DataTemplate x:DataType="vm:PhaseStepEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" <Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8"> BorderThickness="0,0,0,1" Padding="0,8">
<Grid ColumnDefinitions="140,130,*,110,100,26,26,26"> <Grid ColumnDefinitions="130,95,120,*,100,95,26,26,26">
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung" <TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung"
VerticalAlignment="Top" Margin="0,0,6,0"/> VerticalAlignment="Top" Margin="0,0,6,0"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" <CheckBox Grid.Column="1" IsChecked="{Binding HasAlternativePath}"
Content="{Binding AlternativePathName}" Foreground="{Binding AlternativePathColorHex}"
FontSize="12" VerticalAlignment="Top" Margin="0,4,6,0"
ToolTip.Tip="Teil eines alternativen Ablaufs (z.B. Kurzversion bei Zeitnot). Ankreuzen zum Zuweisen/Anlegen, abwählen zum Entfernen."/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6"
VerticalAlignment="Top" Margin="0,0,6,0"> VerticalAlignment="Top" Margin="0,0,6,0">
<NumericUpDown Value="{Binding DurationMinutes}" Minimum="0" Maximum="180" Width="62" <NumericUpDown Value="{Binding DurationMinutes}" Minimum="0" Maximum="180" Width="62"
FormatString="0" ShowButtonSpinner="False" ToolTip.Tip="Dauer in Minuten"/> FormatString="0" ShowButtonSpinner="False" ToolTip.Tip="Dauer in Minuten"/>
<TextBlock Text="{Binding ComputedTimeDisplay}" FontSize="12" Opacity="0.6" <TextBlock Text="{Binding ComputedTimeDisplay}" FontSize="12" Opacity="0.6"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
<TextBox Grid.Column="2" Text="{Binding Activity}" AcceptsReturn="True" TextWrapping="Wrap" <TextBox Grid.Column="3" Text="{Binding Activity}" AcceptsReturn="True" TextWrapping="Wrap"
Height="56" VerticalAlignment="Top" Margin="0,0,6,0" Height="56" VerticalAlignment="Top" Margin="0,0,6,0"
PlaceholderText="Lehrer-/Schüler-Tätigkeit" PlaceholderText="Lehrer-/Schüler-Tätigkeit"
ToolTip.Tip="Bei mehr Text scrollt das Feld intern."/> ToolTip.Tip="Bei mehr Text scrollt das Feld intern."/>
<AutoCompleteBox Grid.Column="3" Text="{Binding Material}" <AutoCompleteBox Grid.Column="4" Text="{Binding Material}"
ItemsSource="{Binding $parent[ItemsControl].((vm:LessonDialogViewModel)DataContext).MaterialSuggestions}" ItemsSource="{Binding $parent[ItemsControl].((vm:LessonDialogViewModel)DataContext).MaterialSuggestions}"
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top" FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
Margin="0,0,6,0" PlaceholderText="z.B. AB01"/> Margin="0,0,6,0" PlaceholderText="z.B. AB01"/>
<AutoCompleteBox Grid.Column="4" Text="{Binding Shorthand}" <AutoCompleteBox Grid.Column="5" Text="{Binding Shorthand}"
ItemsSource="{Binding $parent[ItemsControl].((vm:LessonDialogViewModel)DataContext).ShorthandSuggestions}" ItemsSource="{Binding $parent[ItemsControl].((vm:LessonDialogViewModel)DataContext).ShorthandSuggestions}"
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top" FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
Margin="0,0,6,0" PlaceholderText="z.B. AB001-&gt;S, Plenum, LDE" Margin="0,0,6,0" PlaceholderText="z.B. AB001-&gt;S, Plenum, LDE"
ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001-&gt;S), mal nur eine Sozialform (Plenum, LDE)."/> ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001-&gt;S), mal nur eine Sozialform (Plenum, LDE)."/>
<Button Grid.Column="5" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2" <Button Grid.Column="6" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
VerticalAlignment="Top" ToolTip.Tip="Nach oben"/> VerticalAlignment="Top" ToolTip.Tip="Nach oben"/>
<Button Grid.Column="6" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2" <Button Grid.Column="7" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Nach unten"/> VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Nach unten"/>
<Button Grid.Column="7" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2" <Button Grid.Column="8" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/> VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/>
</Grid> </Grid>
</Border> </Border>
@@ -1,6 +1,9 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -8,6 +11,23 @@ public partial class LessonDialog : Window
{ {
public LessonDialog() => InitializeComponent(); public LessonDialog() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is LessonDialogViewModel vm)
vm.OnPickAlternativePath = ShowAlternativePathDialog;
}
private async Task<AlternativeLessonPath?> ShowAlternativePathDialog(Guid? currentId)
{
var dialogVm = new AlternativePathDialogViewModel(
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(), currentId);
var dialog = new AlternativePathDialog { DataContext = dialogVm };
var ok = await dialog.ShowDialog<bool>(this);
return ok ? dialogVm.Result : null;
}
private void OnSave(object? s, RoutedEventArgs e) private void OnSave(object? s, RoutedEventArgs e)
{ {
if (DataContext is LessonDialogViewModel vm && vm.SaveCommand.CanExecute(null)) if (DataContext is LessonDialogViewModel vm && vm.SaveCommand.CanExecute(null))
@@ -0,0 +1,90 @@
<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.LessonViewerDialog"
x:DataType="vm:LessonViewerViewModel"
Title="{Binding Topic}"
Width="900" Height="700" MinWidth="600" MinHeight="420"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,16">
<TextBlock Text="{Binding Topic}" FontSize="20" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="16">
<TextBlock FontSize="13" Opacity="0.7">
<Run Text="Datum: "/><Run Text="{Binding DateDisplay}"/>
</TextBlock>
<TextBlock FontSize="13" Opacity="0.7">
<Run Text="Beginn: "/><Run Text="{Binding StartTimeDisplay}"/>
</TextBlock>
<TextBlock FontSize="13" Opacity="0.7">
<Run Text="Status: "/><Run Text="{Binding StatusLabel}"/>
</TextBlock>
</StackPanel>
</StackPanel>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="0">
<Grid ColumnDefinitions="140,80,*,140,130" Margin="4,0,0,6">
<TextBlock Grid.Column="0" Text="Phase" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="1" Text="Dauer/Zeit" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Tätigkeit" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Material" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Kurzsymbol" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
</Grid>
<Separator/>
<ItemsControl ItemsSource="{Binding PhaseGroups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseGroupViewItem">
<StackPanel Margin="0,0,0,14">
<StackPanel IsVisible="{Binding $parent[ItemsControl].((vm:LessonViewerViewModel)DataContext).HasAlternatives}">
<TextBlock Text="{Binding Label}" FontSize="13" FontWeight="SemiBold" Opacity="0.75"
Margin="4,10,0,2"/>
<TextBlock Text="{Binding Description}" FontSize="12" Opacity="0.55" TextWrapping="Wrap"
Margin="4,0,0,6"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseViewItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="4,10">
<Grid ColumnDefinitions="140,80,*,140,130">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="15" FontWeight="SemiBold"
TextWrapping="Wrap" VerticalAlignment="Top" Margin="0,0,8,0"/>
<StackPanel Grid.Column="1" VerticalAlignment="Top">
<TextBlock Text="{Binding DurationMinutes, StringFormat='{}{0} Min.'}" FontSize="14"/>
<TextBlock Text="{Binding TimeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding Activity}" FontSize="14"
TextWrapping="Wrap" VerticalAlignment="Top" Margin="0,0,8,0"/>
<TextBlock Grid.Column="3" Text="{Binding Material}" FontSize="14"
TextWrapping="Wrap" VerticalAlignment="Top" Margin="0,0,8,0"/>
<TextBlock Grid.Column="4" Text="{Binding Shorthand}" FontSize="14"
TextWrapping="Wrap" VerticalAlignment="Top"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Spacing="4" Margin="4,16,0,0" IsVisible="{Binding Homework, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Hausaufgabe" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Text="{Binding Homework}" FontSize="14" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" Margin="4,16,0,0" IsVisible="{Binding Reflection, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Reflexion" FontSize="12" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Text="{Binding Reflection}" FontSize="14" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Button Grid.Row="2" Content="Schließen" HorizontalAlignment="Right" Margin="0,16,0,0" Click="OnClose"/>
</Grid>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class LessonViewerDialog : Window
{
public LessonViewerDialog() => InitializeComponent();
private void OnClose(object? s, RoutedEventArgs e) => Close();
}
@@ -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="Anzeigen" Command="{Binding ShowLessonCommand}"
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}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}"/> <Button Content="Verschieben" Command="{Binding MoveLessonCommand}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}"/> <Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}"/>
@@ -26,6 +26,7 @@ public partial class PlanningTabView : UserControl
ShowLessonDialog(lesson.UnitId, lesson.GroupId, materials, shorthands, editingLesson: lesson); ShowLessonDialog(lesson.UnitId, lesson.GroupId, materials, shorthands, editingLesson: lesson);
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog; vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
vm.OnPickMoveTarget = ShowMoveLessonDialog; vm.OnPickMoveTarget = ShowMoveLessonDialog;
vm.OnShowLesson = ShowLessonViewerDialog;
} }
} }
@@ -78,6 +79,7 @@ public partial class PlanningTabView : UserControl
var dialogVm = new LessonDialogViewModel( var dialogVm = new LessonDialogViewModel(
App.Services.GetRequiredService<ILessonRepository>(), App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<IShorthandCodeRepository>(), App.Services.GetRequiredService<IShorthandCodeRepository>(),
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
unitId, groupId, materialSuggestions, shorthandHistorySuggestions, editingLesson); unitId, groupId, materialSuggestions, shorthandHistorySuggestions, editingLesson);
var dialog = new LessonDialog { DataContext = dialogVm }; var dialog = new LessonDialog { DataContext = dialogVm };
@@ -111,4 +113,13 @@ public partial class PlanningTabView : UserControl
var ok = await dialog.ShowDialog<bool>(owner); var ok = await dialog.ShowDialog<bool>(owner);
return ok ? dialogVm.Result : null; return ok ? dialogVm.Result : null;
} }
private async Task ShowLessonViewerDialog(Lesson lesson)
{
var viewerVm = new LessonViewerViewModel(lesson,
App.Services.GetRequiredService<IAlternativeLessonPathRepository>());
var dialog = new LessonViewerDialog { DataContext = viewerVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
} }
+45 -1
View File
@@ -314,13 +314,57 @@ brauchen:
müsste. müsste.
- "Fertig"/"Überziehen" je Phase anklickbar, nachfolgende Phasenzeiten passen sich automatisch an; - "Fertig"/"Überziehen" je Phase anklickbar, nachfolgende Phasenzeiten passen sich automatisch an;
Visualisierung, wie weit man dem Plan hinterherhängt. Visualisierung, wie weit man dem Plan hinterherhängt.
- Hinterlegte alternative Stundenenden, in die man bei Zeitnot direkt hineinspringen kann. - Live-Sprung zwischen Wegen während des Haltens der Stunde (per Klick direkt in den alternativen
Ablauf springen) — die Planungs-/Anzeigeseite (Kennzeichnen, gruppierte Darstellung) ist bereits
umgesetzt, siehe Nachtrag unten; nur die Live-Interaktion beim Halten fehlt noch.
- "Phase in nächste Stunde schieben"-Aktion, mit Hinweis-Symbol an der Folgestunde, dass dort noch - "Phase in nächste Stunde schieben"-Aktion, mit Hinweis-Symbol an der Folgestunde, dass dort noch
offene Phasen/Arbeitsaufträge der Vorstunde einzuplanen sind. offene Phasen/Arbeitsaufträge der Vorstunde einzuplanen sind.
- Phasen-"Parkplatz": beim Planen Phasen ablegen können, ohne sie sofort einer Stunde zuzuordnen, - Phasen-"Parkplatz": beim Planen Phasen ablegen können, ohne sie sofort einer Stunde zuzuordnen,
und später flexibel in eine beliebige Stunde einfügen — Werkzeug, um Einheiten während des und später flexibel in eine beliebige Stunde einfügen — Werkzeug, um Einheiten während des
Schuljahres an die Realität anzupassen, wenn der Unterricht nicht wie geplant läuft. Schuljahres an die Realität anzupassen, wenn der Unterricht nicht wie geplant läuft.
**Nachtrag — schreibgeschützte Verlaufsplan-Ansicht:** Um den Verlaufsplan-Editor überhaupt im
Unterricht benutzen zu können, ohne versehentlich etwas zu verändern, gibt es jetzt
`LessonViewerDialog` (erreichbar über "Anzeigen" im Stunden-Toolbar, sichtbar bei ausgewählter
Stunde) — reine, größer geschriebene Anzeige des Verlaufsplans inkl. abgeleiteter Uhrzeit je
Phase, ohne jede Bearbeitungs- oder Verlängern-/Verschieben-Funktion. Das ist bewusst nur ein
Ausschnitt aus der "Fortschrittslinie"-Idee oben (rein statische Anzeige, keine Live-Aktualisierung
nach Uhrzeit, kein "fertig"/"überziehen") — der volle Live-Modus bleibt Ideensammlung.
**Nachtrag — Alternative Unterrichtsabläufe, zweite Iteration (echter Katalog statt Freitext):**
Die erste Umsetzung (`LessonPhaseStep.AlternativePath: string?`, freier Bezeichner, Zuweisung über
eine durchgehend sichtbare `AutoCompleteBox`-Spalte) wirkte im Alltag zu prominent für einen Fall,
der selten vorkommt. Nutzer-Feedback: eine unauffällige Checkbox reicht für den Normalfall, die
eigentliche Zuordnung gehört in einen separaten Dialog, und ein alternativer Ablauf sollte eine
Erklärung tragen können (wann/wofür er gedacht ist, z.B. "bei Zeitmangel").
Redesign:
- Neues Modell [`AlternativeLessonPath`](LehrerApp.Core/Models/Planning.cs) (Name + optionale
`Description`) als eigener, wiederverwendbarer Katalog — `LessonPhaseStep.AlternativePathId:
Guid?` referenziert einen Eintrag statt Freitext direkt zu speichern (`null` = Hauptweg).
`IAlternativeLessonPathRepository`/`AlternativeLessonPathRepository`, eindeutig auf `Name`,
kein eigenes Verwaltungs-UI in den Einstellungen — Einträge entstehen inline beim Zuweisen.
- **Editor**: Die "Pfad"-Spalte ist jetzt eine einzelne `CheckBox` (`PhaseStepEditItem.
HasAlternativePath`), deren `Content` bei Zuordnung den Namen des Ablaufs zeigt, `Foreground`
deterministisch aus dem Namen eingefärbt (kleines, farbig unterstütztes Label statt breiter
Eingabespalte). Ankreuzen öffnet `AlternativePathDialog` (Combobox über vorhandene Abläufe +
Formular zum Anlegen eines neuen inkl. Beschreibung); Abwählen entfernt die Zuordnung direkt
ohne Dialog. `LessonDialogViewModel.OnPickAlternativePath` (vom Code-Behind von `LessonDialog`
selbst verdrahtet, nicht vom übergeordneten `PlanningTabView` — der Dialog braucht das
`LessonDialog`-Fenster als Owner) kapselt diesen Ablauf.
- **Viewer**: `LessonViewerViewModel` löst `AlternativePathId` über den Katalog auf und zeigt die
hinterlegte Beschreibung unter der Gruppenüberschrift.
- **Migration v3→v4** (`MigrateLessonAlternativePaths`): alte `AlternativePath`-Freitextwerte
werden pro distinktem Namen zu einem `AlternativeLessonPath`-Katalogeintrag zusammengeführt
(gleicher Name über mehrere Stunden hinweg → derselbe Eintrag, wiederverwendet) und referenziert.
- Weiterhin bewusst **kein** Bedingungsfeld ("wenn an Stelle X nur noch weniger als Y Minuten") —
das setzt Live-Zeiterfassung während des Unterrichtens voraus (siehe Live-Unterrichtsmodus oben)
und war ausdrücklich als Idee, nicht als Anforderung formuliert. `AlternativeLessonPath.
Description` deckt die Erklärung als Freitext ab; eine echte Bedingung bliebe für später.
- Bewusst weiterhin **kein** Fork-Punkt-Modell: jede Gruppe berechnet ihre abgeleitete Uhrzeit im
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
### 4.3 Stundenplan ### 4.3 Stundenplan
- [ ] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository. - [ ] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository.
- [ ] **4.3.2** Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe. - [ ] **4.3.2** Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe.
+20 -6
View File
@@ -100,8 +100,17 @@ der Praxis ist es mal ein Materialfluss-Pfeil ("AB001->S"), mal nur eine Sozialf
Stunden verwendete Werte dienen nur als Autovervollständigungs-Vorschläge, erzwingen aber keine Stunden verwendete Werte dienen nur als Autovervollständigungs-Vorschläge, erzwingen aber keine
Struktur. Struktur.
`LiteDbContext`-Schema-Version 3 führt zwei aufeinanderfolgende, unabhängig versionierte `AlternativePathId` (`Guid?`, `null` = Hauptweg) verweist auf einen Eintrag im eigenständigen
Migrationsschritte für dieses Feld: Katalog `AlternativeLessonPath` (Name + optionale `Description`, verwaltet über
`IAlternativeLessonPathRepository`) — anders als `Shorthand` bewusst kein Freitext direkt am
Phasen-Datensatz: die Zuordnung muss zuverlässig gruppierbar sein (`LessonViewerViewModel`
gruppiert danach für die Anzeige) und soll eine wiederverwendbare Erklärung tragen können ("wann
nimmt man diesen Weg"), was ein reines Freitextfeld nicht sauber leisten kann. Kein
Fork-Punkt-Bezug zum Hauptweg: jede Gruppe wird beim Anzeigen unabhängig ab `Lesson.StartTime`
durchgerechnet, nicht ab einer gemeinsamen Verzweigungsstelle.
`LiteDbContext`-Schema-Version 4 führt drei aufeinanderfolgende, unabhängig versionierte
Migrationsschritte für dieses Feld bzw. seine Vorstufen:
- **v1→v2** (`MigrateLessonPhases()`): führt bereits gespeicherte alte Stunden (einzelnes - **v1→v2** (`MigrateLessonPhases()`): führt bereits gespeicherte alte Stunden (einzelnes
`Phase`-Feld, `Methods`/`Materials`-Listen) verlustfrei in eine einzige synthetisierte `Phase`-Feld, `Methods`/`Materials`-Listen) verlustfrei in eine einzige synthetisierte
`LessonPhaseStep`-Zeile zusammen (`Name` = altes `Phase`, `Activity` = alte `Methods` verbunden, `LessonPhaseStep`-Zeile zusammen (`Name` = altes `Phase`, `Activity` = alte `Methods` verbunden,
@@ -109,11 +118,16 @@ Migrationsschritte für dieses Feld:
- **v2→v3** (`MigrateLessonShorthand()`): das ursprünglich als Von/Nach-Paar (`ShorthandFrom`/ - **v2→v3** (`MigrateLessonShorthand()`): das ursprünglich als Von/Nach-Paar (`ShorthandFrom`/
`ShorthandTo`) modellierte Kurzsymbol wird auf das einzelne Freitextfeld zusammengeführt (beide `ShorthandTo`) modellierte Kurzsymbol wird auf das einzelne Freitextfeld zusammengeführt (beide
gesetzt → `"Von->Nach"`, nur eines gesetzt → dieser Einzelwert). gesetzt → `"Von->Nach"`, nur eines gesetzt → dieser Einzelwert).
- **v3→v4** (`MigrateLessonAlternativePaths()`): das ursprünglich als Freitext (`AlternativePath`,
string) modellierte Kennzeichen für den alternativen Ablauf wird pro distinktem Namen zu einem
`AlternativeLessonPath`-Katalogeintrag zusammengeführt (derselbe Name über mehrere Stunden hinweg
referenziert denselben, wiederverwendeten Eintrag) und die Phase auf `AlternativePathId`
umgestellt.
Beide Migrationen lesen dafür die rohe `BsonDocument`-Repräsentation der `lessons`-Collection statt Alle drei Migrationen lesen dafür die rohe `BsonDocument`-Repräsentation der `lessons`-Collection
der typisierten `Lessons`-Collection — nach jeder Modelländerung kennt die typisierte `Lesson`- statt der typisierten `Lessons`-Collection — nach jeder Modelländerung kennt die typisierte
Klasse die alten Feldnamen nicht mehr, ein Zugriff darüber hätte sie beim Deserialisieren bereits `Lesson`-Klasse die alten Feldnamen nicht mehr, ein Zugriff darüber hätte sie beim Deserialisieren
verworfen, bevor sie gelesen werden können. bereits verworfen, bevor sie gelesen werden können.
## Eindeutige Schlüssel ## Eindeutige Schlüssel