diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index b06a63c..a2e7b93 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -157,3 +157,9 @@ public interface ICompetencyDomainRepository void Delete(Guid id); void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel); } +public interface IShorthandCodeRepository +{ + List GetAll(); + void Save(ShorthandCode code); + void Delete(Guid id); +} diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 4e75f07..c1996a3 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -51,14 +51,36 @@ public class Lesson public DateOnly Date { get; set; } public int? LessonNumber { get; set; } public string Topic { get; set; } = ""; - public string? Phase { get; set; } - public List Methods { get; set; } = []; - public List Materials { get; set; } = []; + /// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell + /// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in . + public TimeOnly? StartTime { get; set; } + public List Phases { get; set; } = []; public string? Homework { get; set; } public string? Reflection { get; set; } public LessonStatus Status { get; set; } = LessonStatus.Planned; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } + +/// +/// Eine Zeile im tabellarischen Stundenverlaufsplan (Phase/Dauer/Tätigkeit/Material/Kurzsymbol). +/// ist die primäre Eingabe (man plant in Zeitblöcken); die Uhrzeit +/// je Phase wird im Editor daraus abgeleitet, wenn gesetzt ist. +/// ist bewusst ein einzelnes Freitextfeld statt einer erzwungenen +/// Von/Nach-Struktur: manchmal ist es ein Materialfluss-Pfeil ("AB001->S"), manchmal nur eine +/// Sozialform ohne Pfeil ("Plenum", "LDE"). Vorschläge kommen sowohl aus dem in den Einstellungen +/// gepflegten Kürzel-Katalog () als auch aus bereits in anderen Stunden +/// verwendeten Werten. +/// +public class LessonPhaseStep +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = ""; + public int DurationMinutes { get; set; } + public string Activity { get; set; } = ""; + public string Material { get; set; } = ""; + public string Shorthand { get; set; } = ""; +} + public enum UnitStatus { Planned, Active, Completed } public enum LessonStatus { Planned, Conducted } diff --git a/LehrerApp.Core/Models/ShorthandCode.cs b/LehrerApp.Core/Models/ShorthandCode.cs new file mode 100644 index 0000000..264f6ca --- /dev/null +++ b/LehrerApp.Core/Models/ShorthandCode.cs @@ -0,0 +1,29 @@ +namespace LehrerApp.Core.Models; + +/// +/// Ein Eintrag im Kürzel-Katalog (Einstellungen), z.B. Code="Tb", Label="Tafelbild". Wird im +/// Stundeneditor als Autovervollständigungs-Vorschlag für das freie Kurzsymbol-Feld +/// angeboten. +/// +public class ShorthandCode +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Code { get; set; } = ""; + public string Label { get; set; } = ""; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// Startwerte für die Von/Nach-Vorschläge, solange der Katalog in den Einstellungen leer ist — +/// kein DB-Seed, analog zu . +public static class DefaultShorthandCodes +{ + public static readonly IReadOnlyList All = + [ + new() { Code = "L", Label = "Lehrer" }, + new() { Code = "S", Label = "Schüler" }, + new() { Code = "Tb", Label = "Tafelbild" }, + new() { Code = "SH", Label = "Schülerheft" }, + new() { Code = "AB", Label = "Arbeitsblatt" }, + new() { Code = "GA", Label = "Gruppenarbeit" }, + ]; +} diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs index 6c3faf5..6fe1dd7 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(1, context.SchemaVersion); + Assert.Equal(3, context.SchemaVersion); } [Fact] @@ -132,7 +132,96 @@ public sealed class LiteDbContextTests using var second = new LiteDbContext(temp.Path); - Assert.Equal(1, second.SchemaVersion); + Assert.Equal(3, second.SchemaVersion); + } + + [Fact] + public void MigrateLessonPhases_FasstAltePhaseMethodenUndMaterialienVerlustfreiZusammen() + { + using var temp = new TempDatabase(); + var lessonWithDataId = Guid.NewGuid(); + var lessonEmptyId = Guid.NewGuid(); + + using (var legacy = new LiteDatabase(temp.Path)) + { + legacy.GetCollection("lessons").Insert(new BsonDocument + { + ["_id"] = lessonWithDataId, + [nameof(Lesson.UnitId)] = Guid.NewGuid(), + [nameof(Lesson.GroupId)] = Guid.NewGuid(), + ["Phase"] = "Einstieg", + ["Methods"] = new BsonArray { "Gespräch", "Tafelbild" }, + ["Materials"] = new BsonArray { "Arbeitsblatt" }, + }); + // Alte Stunde ganz ohne Phase/Methoden/Materialien — soll leere Phases-Liste bekommen, + // nicht eine leere synthetisierte Zeile. + legacy.GetCollection("lessons").Insert(new BsonDocument + { + ["_id"] = lessonEmptyId, + [nameof(Lesson.UnitId)] = Guid.NewGuid(), + [nameof(Lesson.GroupId)] = Guid.NewGuid(), + }); + } + + using (var context = new LiteDbContext(temp.Path)) + { + var withData = context.Lessons.FindById(lessonWithDataId); + Assert.NotNull(withData); + Assert.Single(withData.Phases); + Assert.Equal("Einstieg", withData.Phases[0].Name); + Assert.Equal("Gespräch; Tafelbild", withData.Phases[0].Activity); + Assert.Equal("Arbeitsblatt", withData.Phases[0].Material); + Assert.Equal(0, withData.Phases[0].DurationMinutes); + + var empty = context.Lessons.FindById(lessonEmptyId); + Assert.NotNull(empty); + Assert.Empty(empty.Phases); + } + + using var migrated = new LiteDatabase(temp.Path); + var raw = migrated.GetCollection("lessons").FindById(lessonWithDataId); + Assert.False(raw.ContainsKey("Phase")); + Assert.False(raw.ContainsKey("Methods")); + Assert.False(raw.ContainsKey("Materials")); + } + + [Fact] + public void MigrateLessonShorthand_FasstVonUndNachInEinFreitextfeldZusammen() + { + using var temp = new TempDatabase(); + var lessonId = Guid.NewGuid(); + + using (var legacy = new LiteDatabase(temp.Path)) + { + legacy.GetCollection("lessons").Insert(new BsonDocument + { + ["_id"] = lessonId, + [nameof(Lesson.UnitId)] = Guid.NewGuid(), + [nameof(Lesson.GroupId)] = Guid.NewGuid(), + [nameof(Lesson.Phases)] = new BsonArray + { + new BsonDocument { ["ShorthandFrom"] = "Tb", ["ShorthandTo"] = "SH" }, + new BsonDocument { ["ShorthandFrom"] = "Plenum" }, // nur "Von" gesetzt, kein Pfeil + new BsonDocument { }, // weder Von noch Nach + }, + }); + } + + using (var context = new LiteDbContext(temp.Path)) + { + var lesson = context.Lessons.FindById(lessonId); + Assert.NotNull(lesson); + Assert.Equal(3, lesson.Phases.Count); + Assert.Equal("Tb->SH", lesson.Phases[0].Shorthand); + Assert.Equal("Plenum", lesson.Phases[1].Shorthand); + Assert.Equal("", lesson.Phases[2].Shorthand); + } + + using var migratedShorthand = new LiteDatabase(temp.Path); + var rawLesson = migratedShorthand.GetCollection("lessons").FindById(lessonId); + var rawPhases = rawLesson["Phases"].AsArray; + Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandFrom")); + Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandTo")); } private sealed class TempDatabase : IDisposable diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index ca7b3eb..17b5501 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -424,4 +424,68 @@ public sealed class RepositoryTests Assert.Null(db.Documentation.FindById(doc.Id)); Assert.Null(storage.OpenRead(attachmentId)); } + + // ── LessonRepository ────────────────────────────────────────────────────── + + [Fact] + public void LessonRepository_GetByUnit_SortiertNachDatumDannStundennummer() + { + using var db = NewInMemoryContext(); + var repo = new LessonRepository(db); + var unitId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + + // Doppelstunde am selben Tag: Stunde 2 vor Stunde 1 eingefügt, muss trotzdem + // nach LessonNumber sortiert erscheinen. + repo.Save(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2025, 9, 8), LessonNumber = 2 }); + repo.Save(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2025, 9, 8), LessonNumber = 1 }); + repo.Save(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2025, 9, 1), LessonNumber = 1 }); + + var result = repo.GetByUnit(unitId); + + Assert.Equal(new DateOnly(2025, 9, 1), result[0].Date); + Assert.Equal(new DateOnly(2025, 9, 8), result[1].Date); + Assert.Equal(1, result[1].LessonNumber); + Assert.Equal(new DateOnly(2025, 9, 8), result[2].Date); + Assert.Equal(2, result[2].LessonNumber); + } + + // ── ShorthandCodeRepository ─────────────────────────────────────────────── + + [Fact] + public void ShorthandCodeRepository_Save_LehntDuplikatCodeAb() + { + using var db = NewInMemoryContext(); + var repo = new ShorthandCodeRepository(db); + repo.Save(new ShorthandCode { Code = "Tb", Label = "Tafelbild" }); + + Assert.Throws(() => + repo.Save(new ShorthandCode { Code = "tb", Label = "Anderes Tafelbild" })); + } + + [Fact] + public void ShorthandCodeRepository_GetAll_SortiertNachCode() + { + using var db = NewInMemoryContext(); + var repo = new ShorthandCodeRepository(db); + repo.Save(new ShorthandCode { Code = "SH", Label = "Schülerheft" }); + repo.Save(new ShorthandCode { Code = "AB", Label = "Arbeitsblatt" }); + + var result = repo.GetAll(); + + Assert.Equal(["AB", "SH"], result.Select(c => c.Code)); + } + + [Fact] + public void ShorthandCodeRepository_Delete_EntferntEintrag() + { + using var db = NewInMemoryContext(); + var repo = new ShorthandCodeRepository(db); + repo.Save(new ShorthandCode { Code = "Tb", Label = "Tafelbild" }); + 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 1cfcb1d..11d1ca8 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 = 1; + private const int CurrentSchemaVersion = 3; private readonly LiteDatabase _db; @@ -55,6 +55,7 @@ public class LiteDbContext : IDisposable public ILiteCollection ParticipationSections => _db.GetCollection("participation_sections"); public ILiteCollection Subjects => _db.GetCollection("subjects"); public ILiteCollection CompetencyDomains => _db.GetCollection("competency_domains"); + public ILiteCollection ShorthandCodes => _db.GetCollection("shorthand_codes"); public void Checkpoint() => _db.Checkpoint(); @@ -83,6 +84,16 @@ public class LiteDbContext : IDisposable MigrateExistingData(); version = 1; } + if (version < 2) + { + MigrateLessonPhases(); + version = 2; + } + if (version < 3) + { + MigrateLessonShorthand(); + version = 3; + } WriteSchemaVersion(version); } @@ -174,6 +185,79 @@ public class LiteDbContext : IDisposable } } + /// Fasst die frühere flache Struktur einer Stunde (einzelnes Phase-Textfeld, Methoden-/ + /// Materialien-Listen) verlustfrei in eine einzige -Zeile der + /// neuen Verlaufsplan-Tabelle zusammen. Nur die alten Felder werden gelesen — die typisierte + /// -Klasse kennt sie nicht mehr, ein Zugriff über die typisierte + /// Lessons-Collection würde sie beim Deserialisieren bereits verwerfen. + private void MigrateLessonPhases() + { + var lessons = _db.GetCollection("lessons"); + foreach (var lesson in lessons.FindAll().ToList()) + { + if (lesson.ContainsKey(nameof(Lesson.Phases))) continue; + + var phase = lesson.TryGetValue("Phase", out var p) && p.IsString ? p.AsString.Trim() : ""; + var methods = lesson.TryGetValue("Methods", out var m) && m.IsArray + ? string.Join("; ", m.AsArray.Select(x => x.AsString)) : ""; + var materials = lesson.TryGetValue("Materials", out var mat) && mat.IsArray + ? string.Join(", ", mat.AsArray.Select(x => x.AsString)) : ""; + + var phases = new BsonArray(); + if (phase.Length > 0 || methods.Length > 0 || materials.Length > 0) + { + phases.Add(new BsonDocument + { + [nameof(LessonPhaseStep.Id)] = Guid.NewGuid(), + [nameof(LessonPhaseStep.Name)] = phase, + [nameof(LessonPhaseStep.DurationMinutes)] = 0, + [nameof(LessonPhaseStep.Activity)] = methods, + [nameof(LessonPhaseStep.Material)] = materials, + [nameof(LessonPhaseStep.Shorthand)] = "", + }); + } + lesson[nameof(Lesson.Phases)] = phases; + lesson.Remove("Phase"); + lesson.Remove("Methods"); + lesson.Remove("Materials"); + lessons.Update(lesson); + } + } + + /// Führt die anfangs erzwungene Von/Nach-Struktur des Kurzsymbols (ShorthandFrom/ + /// ShorthandTo) in ein einzelnes Freitextfeld zusammen — der Praxis nach ist ein + /// Kurzsymbol nicht immer ein Materialfluss-Pfeil, manchmal nur eine Sozialform ("Plenum"). + /// Beide Felder gesetzt ergeben "Von->Nach", nur eines gesetzt bleibt als Einzelwert erhalten. + private void MigrateLessonShorthand() + { + var lessons = _db.GetCollection("lessons"); + 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.ContainsKey(nameof(LessonPhaseStep.Shorthand))) continue; + + var from = phase.TryGetValue("ShorthandFrom", out var f) && f.IsString ? f.AsString : ""; + var to = phase.TryGetValue("ShorthandTo", out var t) && t.IsString ? t.AsString : ""; + phase[nameof(LessonPhaseStep.Shorthand)] = (from.Length > 0, to.Length > 0) switch + { + (true, true) => $"{from}->{to}", + (true, false) => from, + (false, true) => to, + _ => "", + }; + phase.Remove("ShorthandFrom"); + phase.Remove("ShorthandTo"); + changed = true; + } + if (changed) lessons.Update(lesson); + } + } + private void RemoveRedundantLegacyFields() { RemoveFields("exams", "Subject"); @@ -237,6 +321,7 @@ public class LiteDbContext : IDisposable Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); CompetencyDomains.EnsureIndex(x => x.SubjectId); CompetencyDomains.EnsureIndex(x => x.GradeLevel); + ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true); } public void Dispose() => _db.Dispose(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index ac6dd28..72cd59b 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -211,7 +211,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository public class LessonRepository(LiteDbContext db) : ILessonRepository { public List GetByUnit(Guid id) => - db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ToList(); + db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList(); public List GetByGroupAndDate(Guid gid, DateOnly date) => db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList(); public List GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) => @@ -349,6 +349,24 @@ public class SubjectRepository(LiteDbContext db) : ISubjectRepository } } +public class ShorthandCodeRepository(LiteDbContext db) : IShorthandCodeRepository +{ + public List GetAll() => db.ShorthandCodes.FindAll().OrderBy(c => c.Code).ToList(); + public void Save(ShorthandCode c) + { + c.Code = c.Code.Trim(); + c.Label = c.Label.Trim(); + if (c.Code.Length == 0) throw new ArgumentException("Das Kürzel darf nicht leer sein."); + var duplicate = db.ShorthandCodes.FindAll() + .FirstOrDefault(x => string.Equals(x.Code, c.Code, StringComparison.OrdinalIgnoreCase)); + if (duplicate is not null && duplicate.Id != c.Id) + throw new InvalidOperationException("Ein Kürzel mit diesem Code existiert bereits."); + c.UpdatedAt = DateTime.UtcNow; + db.ShorthandCodes.Upsert(c); + } + public void Delete(Guid id) => db.ShorthandCodes.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 e60fdb3..7d811fe 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -148,6 +148,56 @@ public class FakeAttachmentStorage : IAttachmentStorage public void Delete(string storageId) => _blobs.Remove(storageId); } +public class FakeUnits : IUnitRepository +{ + private readonly List _all = []; + public void Add(Unit u) => _all.Add(u); + public Unit? GetById(Guid id) => _all.FirstOrDefault(u => u.Id == id); + public List GetByGroup(Guid groupId) => + _all.Where(u => u.GroupId == groupId).OrderBy(u => u.StartDate).ToList(); + public void Save(Unit unit) { _all.RemoveAll(u => u.Id == unit.Id); _all.Add(unit); } + public void Delete(Guid id) => _all.RemoveAll(u => u.Id == id); +} + +public class FakeLessons : ILessonRepository +{ + private readonly List _all = []; + public void Add(Lesson l) => _all.Add(l); + public List GetByUnit(Guid unitId) => + _all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList(); + public List GetByGroupAndDate(Guid groupId, DateOnly date) => + _all.Where(l => l.GroupId == groupId && l.Date == date).ToList(); + public List GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to) => + _all.Where(l => l.GroupId == groupId && l.Date >= from && l.Date <= to).OrderBy(l => l.Date).ToList(); + public void Save(Lesson lesson) { _all.RemoveAll(l => l.Id == lesson.Id); _all.Add(lesson); } + public void Delete(Guid id) => _all.RemoveAll(l => l.Id == id); +} + +public class FakeSubjects(List all) : ISubjectRepository +{ + public List GetAll() => all; + public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); + public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name); + public void Save(Subject subject) { } + public void Delete(Guid id) { } +} + +public class FakeCompetencyDomains : ICompetencyDomainRepository +{ + public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => []; + public CompetencyDomain? GetById(Guid id) => null; + public void Save(CompetencyDomain domain) { } + public void Delete(Guid id) { } + public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) { } +} + +public class FakeShorthandCodes(List all) : IShorthandCodeRepository +{ + public List GetAll() => all; + public void Save(ShorthandCode code) { } + public void Delete(Guid id) { } +} + public class FakeReportGrades : IReportGradeRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs new file mode 100644 index 0000000..504f4c3 --- /dev/null +++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs @@ -0,0 +1,156 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +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); + + [Fact] + public void AddPhase_FuegtZeileMitStandardwertenHinzu() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + + vm.AddPhaseCommand.Execute(null); + + Assert.Single(vm.Phases); + Assert.Equal(5, vm.Phases[0].DurationMinutes); + } + + [Fact] + public void RemovePhase_EntferntZeileUndAktualisiertGesamtdauer() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 20; + + vm.Phases[0].RemoveCommand.Execute(null); + + Assert.Empty(vm.Phases); + Assert.Equal("0 Minuten gesamt", vm.TotalDurationDisplay); + } + + [Fact] + public void MoveDown_VertauschtReihenfolgeDerPhasen() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].Name = "Einstieg"; + vm.AddPhaseCommand.Execute(null); + vm.Phases[1].Name = "Erarbeitung"; + + vm.Phases[0].MoveDownCommand.Execute(null); + + Assert.Equal("Erarbeitung", vm.Phases[0].Name); + Assert.Equal("Einstieg", vm.Phases[1].Name); + } + + [Fact] + public void Shorthand_IstFreitextOhneErzwungeneVonNachStruktur() + { + // Kurzsymbol muss nicht immer ein Materialfluss-Pfeil sein — manchmal nur eine + // Sozialform ohne Pfeil (z.B. "Plenum", "LDE"). + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.AddPhaseCommand.Execute(null); + var phase = vm.Phases[0]; + + phase.Shorthand = "Plenum"; + Assert.Equal("Plenum", phase.Shorthand); + + phase.Shorthand = "AB001->S"; + Assert.Equal("AB001->S", phase.Shorthand); + } + + [Fact] + 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 + + Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions); + } + + [Fact] + public void RecomputeTimes_LeitetUhrzeitJePhaseAusBeginnUndKumulierterDauerAb() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.StartTimeText = "12:00"; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 15; + vm.AddPhaseCommand.Execute(null); + vm.Phases[1].DurationMinutes = 10; + + Assert.Equal("ab 12:00", vm.Phases[0].ComputedTimeDisplay); + Assert.Equal("ab 12:15", vm.Phases[1].ComputedTimeDisplay); + Assert.Equal("25 Minuten gesamt", vm.TotalDurationDisplay); + } + + [Fact] + public void RecomputeTimes_OhneBeginnBleibtDieUhrzeitanzeigeLeer() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].DurationMinutes = 15; + + Assert.Equal("", vm.Phases[0].ComputedTimeDisplay); + } + + [Fact] + public void Save_ErfordertThemaUndGueltigesDatum() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.Topic = ""; vm.DateText = "keinDatum"; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.TopicError); + Assert.NotEqual("", vm.DateTextError); + } + + [Fact] + public void Save_UngueltigesBeginnFormatWirdAbgelehnt() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.Topic = "Thema"; vm.DateText = "01.09.2025"; vm.StartTimeText = "nicht-uhrzeit"; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEqual("", vm.StartTimeTextError); + } + + [Fact] + public void Save_SetztUnitIdGroupIdUndUebernimmtPhasenUndBeginn() + { + var unitId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var lessons = new FakeLessons(); + var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), unitId, groupId, [], [], null) + { + Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45", + }; + vm.AddPhaseCommand.Execute(null); + vm.Phases[0].Name = "Einstieg"; + vm.Phases[0].DurationMinutes = 10; + vm.Phases[0].Activity = "Begrüßung"; + vm.Phases[0].Material = "AB01"; + vm.Phases[0].Shorthand = "L->AB01"; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal(unitId, vm.Result!.UnitId); + Assert.Equal(groupId, vm.Result.GroupId); + Assert.Equal(new TimeOnly(11, 45), vm.Result.StartTime); + Assert.Single(vm.Result.Phases); + Assert.Equal("Einstieg", vm.Result.Phases[0].Name); + Assert.Equal(10, vm.Result.Phases[0].DurationMinutes); + Assert.Equal("L->AB01", vm.Result.Phases[0].Shorthand); + Assert.Single(lessons.GetByUnit(unitId)); + } +} diff --git a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs new file mode 100644 index 0000000..206bc82 --- /dev/null +++ b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs @@ -0,0 +1,203 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für die Unterrichtsplanung (4.1 Einheiten / 4.2 Einzelstunden): Fortschrittsberechnung, +/// Verschieben mit/ohne Nachrücken der Folgestunden und die Vorlage-Kopie in eine andere Gruppe. +public class PlanningTabViewModelTests +{ + private static (PlanningTabViewModel Vm, FakeUnits Units, FakeLessons Lessons, Guid GroupId) BuildScenario() + { + var groupId = Guid.NewGuid(); + var group = new LearningGroup { Id = groupId, Name = "Testgruppe" }; + var groups = new FakeGroups([group]); + var subjects = new FakeSubjects([]); + var competencyDomains = new FakeCompetencyDomains(); + var units = new FakeUnits(); + var lessons = new FakeLessons(); + + var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains); + vm.Initialize(groupId); + + return (vm, units, lessons, groupId); + } + + [Fact] + public void LoadUnits_BerechnetFortschrittAusDurchgefuehrtenStunden() + { + var (vm, units, lessons, groupId) = BuildScenario(); + var unit = new Unit { GroupId = groupId, Title = "Optik" }; + units.Add(unit); + lessons.Add(new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Conducted, Topic = "A" }); + lessons.Add(new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 8), Status = LessonStatus.Conducted, Topic = "B" }); + lessons.Add(new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 15), Status = LessonStatus.Planned, Topic = "C" }); + + vm.Initialize(groupId); + + var summary = vm.Units.Single(u => u.Id == unit.Id); + Assert.Equal(2, summary.ConductedCount); + Assert.Equal(3, summary.TotalCount); + Assert.Equal(2.0 / 3, summary.ProgressFraction, 3); + Assert.Contains("2 / 3", summary.ProgressText); + } + + [Fact] + public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag() + { + var (vm, units, lessons, groupId) = BuildScenario(); + var unit = new Unit { GroupId = groupId, Title = "Mechanik" }; + units.Add(unit); + var l1 = new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Planned, Topic = "1" }; + var l2 = new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 8), Status = LessonStatus.Planned, Topic = "2" }; + var l3 = new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 15), Status = LessonStatus.Planned, Topic = "3" }; + var l4 = new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 22), Status = LessonStatus.Conducted, Topic = "4" }; + lessons.Add(l1); lessons.Add(l2); lessons.Add(l3); lessons.Add(l4); + + vm.Initialize(groupId); + vm.SelectedUnit = vm.Units.Single(u => u.Id == unit.Id); + vm.SelectedLesson = vm.Lessons.Single(l => l.Id == l1.Id); + vm.OnPickMoveTarget = _ => Task.FromResult(new MoveLessonTarget(new DateOnly(2025, 9, 3), true)); + + await vm.MoveLessonCommand.ExecuteAsync(null); + + var byUnit = lessons.GetByUnit(unit.Id).ToDictionary(l => l.Id); + Assert.Equal(new DateOnly(2025, 9, 3), byUnit[l1.Id].Date); + Assert.Equal(new DateOnly(2025, 9, 10), byUnit[l2.Id].Date); // +2 Tage nachgerückt + Assert.Equal(new DateOnly(2025, 9, 17), byUnit[l3.Id].Date); // +2 Tage nachgerückt + Assert.Equal(new DateOnly(2025, 9, 22), byUnit[l4.Id].Date); // Conducted: unverändert + } + + [Fact] + public async Task MoveLesson_OhneNachrueckenVerschiebtNurDieAusgewaehlteStunde() + { + var (vm, units, lessons, groupId) = BuildScenario(); + var unit = new Unit { GroupId = groupId, Title = "Mechanik" }; + units.Add(unit); + var l1 = new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Planned, Topic = "1" }; + var l2 = new Lesson { UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 8), Status = LessonStatus.Planned, Topic = "2" }; + lessons.Add(l1); lessons.Add(l2); + + vm.Initialize(groupId); + vm.SelectedUnit = vm.Units.Single(u => u.Id == unit.Id); + vm.SelectedLesson = vm.Lessons.Single(l => l.Id == l1.Id); + vm.OnPickMoveTarget = _ => Task.FromResult(new MoveLessonTarget(new DateOnly(2025, 9, 5), false)); + + await vm.MoveLessonCommand.ExecuteAsync(null); + + var byUnit = lessons.GetByUnit(unit.Id).ToDictionary(l => l.Id); + Assert.Equal(new DateOnly(2025, 9, 5), byUnit[l1.Id].Date); + Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert + } + + [Fact] + public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe() + { + var (vm, units, lessons, sourceGroupId) = BuildScenario(); + var targetGroupId = Guid.NewGuid(); + + var unit = new Unit + { + GroupId = sourceGroupId, + Title = "Kinetik", + StartDate = new DateOnly(2025, 9, 1), + EndDate = new DateOnly(2025, 10, 1), + Notes = "Original-Notiz", + }; + units.Add(unit); + var l1 = new Lesson + { + 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" }], + }; + var l2 = new Lesson + { + UnitId = unit.Id, GroupId = sourceGroupId, Date = new DateOnly(2025, 9, 8), + Topic = "Vertiefung", Status = LessonStatus.Planned, + }; + lessons.Add(l1); lessons.Add(l2); + + vm.Initialize(sourceGroupId); + vm.SelectedUnit = vm.Units.Single(u => u.Id == unit.Id); + var anchor = new DateOnly(2025, 10, 1); + vm.OnPickCopyTarget = _ => Task.FromResult(new CopyUnitTarget(targetGroupId, anchor)); + + await vm.CopyUnitCommand.ExecuteAsync(null); + + var newUnit = units.GetByGroup(targetGroupId).Single(); + Assert.Equal("Kinetik", newUnit.Title); + Assert.Equal(UnitStatus.Planned, newUnit.Status); + Assert.Equal(anchor, newUnit.StartDate); + Assert.Equal(anchor.AddDays(30), newUnit.EndDate); // ursprünglich 30 Tage nach StartDate + + var copiedLessons = lessons.GetByUnit(newUnit.Id).OrderBy(l => l.Date).ToList(); + Assert.Equal(2, copiedLessons.Count); + Assert.All(copiedLessons, l => Assert.Equal(targetGroupId, l.GroupId)); // nicht sourceGroupId + Assert.Equal(anchor, copiedLessons[0].Date); + Assert.Equal(anchor.AddDays(7), copiedLessons[1].Date); // 7-Tage-Abstand erhalten + Assert.All(copiedLessons, l => Assert.Equal(LessonStatus.Planned, l.Status)); + Assert.Null(copiedLessons[0].Reflection); // Reflexion wird bei der Kopie geleert + Assert.Equal(new TimeOnly(11, 45), copiedLessons[0].StartTime); // Beginn wird übernommen + Assert.Single(copiedLessons[0].Phases); + Assert.Equal("Einstieg", copiedLessons[0].Phases[0].Name); + Assert.Equal("L->AB01", copiedLessons[0].Phases[0].Shorthand); + 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. + Assert.Single(units.GetByGroup(sourceGroupId)); + Assert.Equal(LessonStatus.Conducted, lessons.GetByUnit(unit.Id).Single(l => l.Id == l1.Id).Status); + } + + [Fact] + public void LoadUnits_SammeltMaterialienAusAllenStundenphasenDerGruppe() + { + var (vm, units, lessons, groupId) = BuildScenario(); + var unit1 = new Unit { GroupId = groupId, Title = "Einheit 1" }; + var unit2 = new Unit { GroupId = groupId, Title = "Einheit 2" }; + units.Add(unit1); units.Add(unit2); + lessons.Add(new Lesson + { + UnitId = unit1.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "A", + Phases = [new LessonPhaseStep { Material = "Arbeitsblatt" }], + }); + lessons.Add(new Lesson + { + UnitId = unit2.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 2), Topic = "B", + Phases = + [ + new LessonPhaseStep { Material = "Modell" }, + new LessonPhaseStep { Material = "Arbeitsblatt" }, // Duplikat, soll nur einmal erscheinen + new LessonPhaseStep { Material = "" }, // leer, soll ignoriert werden + ], + }); + + vm.Initialize(groupId); + + Assert.Equal(["Arbeitsblatt", "Modell"], vm.KnownMaterials); + } + + [Fact] + public void LoadUnits_SammeltKurzsymboleAusAllenStundenphasenDerGruppe() + { + var (vm, units, lessons, groupId) = BuildScenario(); + var unit = new Unit { GroupId = groupId, Title = "Einheit 1" }; + units.Add(unit); + lessons.Add(new Lesson + { + UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "A", + Phases = + [ + new LessonPhaseStep { Shorthand = "Plenum" }, + new LessonPhaseStep { Shorthand = "L->AB01" }, + new LessonPhaseStep { Shorthand = "" }, // leer, soll ignoriert werden + ], + }); + + vm.Initialize(groupId); + + Assert.Equal(["L->AB01", "Plenum"], vm.KnownShorthands); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 034ed2c..381c96f 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -127,6 +127,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── @@ -182,6 +183,7 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 017ab39..1755ae6 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -183,6 +183,7 @@ public partial class GroupDetailViewModel : ObservableObject public ParticipationTabViewModel ParticipationTab { get; } public GradeOverviewTabViewModel GradeOverviewTab { get; } + public PlanningTabViewModel PlanningTab { get; } public Func>? OnAddStudent { get; set; } public Func>? OnAddExam { get; set; } public Func>? OnEditExam { get; set; } @@ -194,12 +195,14 @@ public partial class GroupDetailViewModel : ObservableObject public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IGroupMembershipRepository memberships, ISubjectRepository subjects, IExamRepository exams, IGradeRepository grades, - ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab) + ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, + PlanningTabViewModel planningTab) { _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _exams = exams; _grades = grades; ParticipationTab = participationTab; GradeOverviewTab = gradeOverviewTab; + PlanningTab = planningTab; } public void LoadGroup(Guid id) @@ -217,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject ReloadExams(); ParticipationTab.Initialize(Group.Id, Group.SchoolYear); GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle); + PlanningTab.Initialize(Group.Id); } private void ReloadExams() diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs new file mode 100644 index 0000000..81433d0 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -0,0 +1,796 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Ergebnisse der Verschieben-/Kopieren-Dialoge (4.2.4 / 4.1.4) ───────────── + +public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing); +public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate); + +// ── Tab-ViewModel: Unterrichtsplanung (4.1 Einheiten / 4.2 Einzelstunden) ──── + +public partial class PlanningTabViewModel : ObservableObject +{ + private readonly IUnitRepository _units; + private readonly ILessonRepository _lessons; + private readonly IGroupRepository _groups; + private readonly ISubjectRepository _subjects; + private readonly ICompetencyDomainRepository _competencyDomains; + + private Guid _groupId; + + public Guid GroupId => _groupId; + public Guid? SubjectId { get; private set; } + public int GradeLevel { get; private set; } + public string SubjectName { get; private set; } = ""; + + [ObservableProperty] private UnitSummary? _selectedUnit; + [ObservableProperty] private LessonSummary? _selectedLesson; + + // Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen + // Gruppenwechsel/Laden kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal + // einen Binding-Fehler loggen (siehe GroupDetailViewModel.IsDifferentiated für dasselbe Muster). + public string SelectedUnitTitleSuffix => SelectedUnit is null ? "" : $" – {SelectedUnit.Title}"; + + public ObservableCollection Units { get; } = []; + 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). + public List KnownMaterials { get; private set; } = []; + public List KnownShorthands { get; private set; } = []; + + public Func>? OnAddUnit { get; set; } + public Func>? OnEditUnit { get; set; } + public Func>? OnConfirmDeleteUnit { get; set; } + public Func>? OnPickCopyTarget { get; set; } + public Func, List, Task>? OnAddLesson { get; set; } + public Func, List, Task>? OnEditLesson { get; set; } + public Func>? OnConfirmDeleteLesson { get; set; } + public Func>? OnPickMoveTarget { get; set; } + + public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, + IGroupRepository groups, ISubjectRepository subjects, + ICompetencyDomainRepository competencyDomains) + { + _units = units; _lessons = lessons; _groups = groups; + _subjects = subjects; _competencyDomains = competencyDomains; + } + + public void Initialize(Guid groupId) + { + _groupId = groupId; + var group = _groups.GetById(groupId); + SubjectId = group?.SubjectId; + GradeLevel = group?.GradeLevel ?? 0; + SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : ""; + LoadUnits(); + } + + private void LoadUnits() + { + var selectedId = SelectedUnit?.Id; + Units.Clear(); + + var materials = new HashSet(); + var shorthands = new HashSet(); + + foreach (var unit in _units.GetByGroup(_groupId)) + { + var unitLessons = _lessons.GetByUnit(unit.Id); + foreach (var l in unitLessons) + foreach (var p in l.Phases) + { + if (!string.IsNullOrWhiteSpace(p.Material)) materials.Add(p.Material); + if (!string.IsNullOrWhiteSpace(p.Shorthand)) shorthands.Add(p.Shorthand); + } + Units.Add(new UnitSummary(unit, unitLessons)); + } + + KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList(); + KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList(); + + SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(); + } + + partial void OnSelectedUnitChanged(UnitSummary? value) + { + LoadLessons(); + OnPropertyChanged(nameof(SelectedUnitTitleSuffix)); + EditUnitCommand.NotifyCanExecuteChanged(); + DeleteUnitCommand.NotifyCanExecuteChanged(); + CopyUnitCommand.NotifyCanExecuteChanged(); + AddLessonCommand.NotifyCanExecuteChanged(); + } + + private void LoadLessons() + { + var selectedId = SelectedLesson?.Id; + Lessons.Clear(); + if (SelectedUnit is not null) + foreach (var l in _lessons.GetByUnit(SelectedUnit.Id)) + Lessons.Add(new LessonSummary(l)); + SelectedLesson = Lessons.FirstOrDefault(l => l.Id == selectedId); + } + + partial void OnSelectedLessonChanged(LessonSummary? value) + { + EditLessonCommand.NotifyCanExecuteChanged(); + DeleteLessonCommand.NotifyCanExecuteChanged(); + MoveLessonCommand.NotifyCanExecuteChanged(); + AdvanceLessonStatusCommand.NotifyCanExecuteChanged(); + } + + private bool HasSelectedUnit() => SelectedUnit is not null; + private bool HasSelectedLesson() => SelectedLesson is not null; + + // ── Einheiten (4.1) ──────────────────────────────────────────────────────── + + [RelayCommand] + private async Task AddUnit() + { + if (OnAddUnit is null) return; + if (await OnAddUnit(_groupId)) LoadUnits(); + } + + [RelayCommand(CanExecute = nameof(HasSelectedUnit))] + private async Task EditUnit() + { + if (OnEditUnit is null || SelectedUnit is null) return; + if (await OnEditUnit(SelectedUnit.Model)) LoadUnits(); + } + + [RelayCommand(CanExecute = nameof(HasSelectedUnit))] + private async Task DeleteUnit() + { + if (OnConfirmDeleteUnit is null || SelectedUnit is null) return; + var unit = SelectedUnit; + if (!await OnConfirmDeleteUnit(unit)) return; + foreach (var l in _lessons.GetByUnit(unit.Id)) _lessons.Delete(l.Id); + _units.Delete(unit.Id); + LoadUnits(); + } + + [RelayCommand(CanExecute = nameof(HasSelectedUnit))] + private async Task CopyUnit() + { + if (OnPickCopyTarget is null || SelectedUnit is null) return; + var unit = SelectedUnit.Model; + var target = await OnPickCopyTarget(unit); + if (target is null) return; + CopyUnitAsTemplate(unit, _lessons.GetByUnit(unit.Id), target.TargetGroupId, target.AnchorDate); + LoadUnits(); + } + + /// Kopiert eine Einheit inkl. Stunden in eine andere Gruppe (4.1.4). "Ohne Datumsbezug" + /// bedeutet: die relativen Tages-Abstände der Stunden zueinander bleiben erhalten, werden + /// aber auf das neu gewählte Startdatum re-verankert statt die alten Kalendertage zu übernehmen. + /// Reflexion wird geleert, Status auf "Geplant" zurückgesetzt. GroupId wird auf jeder neuen + /// Lesson explizit auf die Zielgruppe gesetzt (siehe docs/Datenmodell.md). + private void CopyUnitAsTemplate(Unit source, List sourceLessons, Guid targetGroupId, DateOnly anchorDate) + { + var earliest = sourceLessons.Count > 0 + ? sourceLessons.Min(l => l.Date) + : source.StartDate ?? anchorDate; + + var newUnit = new Unit + { + GroupId = targetGroupId, + Title = source.Title, + Competencies = [.. source.Competencies], + Status = UnitStatus.Planned, + Notes = source.Notes, + StartDate = source.StartDate.HasValue + ? anchorDate.AddDays(source.StartDate.Value.DayNumber - earliest.DayNumber) : null, + EndDate = source.EndDate.HasValue + ? anchorDate.AddDays(source.EndDate.Value.DayNumber - earliest.DayNumber) : null, + }; + _units.Save(newUnit); + + foreach (var lesson in sourceLessons) + { + _lessons.Save(new Lesson + { + UnitId = newUnit.Id, + GroupId = targetGroupId, + Date = anchorDate.AddDays(lesson.Date.DayNumber - earliest.DayNumber), + LessonNumber = lesson.LessonNumber, + Topic = lesson.Topic, + StartTime = lesson.StartTime, + Phases = [.. lesson.Phases.Select(p => new LessonPhaseStep + { + Name = p.Name, + DurationMinutes = p.DurationMinutes, + Activity = p.Activity, + Material = p.Material, + Shorthand = p.Shorthand, + })], + Homework = lesson.Homework, + Reflection = null, + Status = LessonStatus.Planned, + }); + } + } + + // ── Einzelstunden (4.2) ─────────────────────────────────────────────────── + + [RelayCommand(CanExecute = nameof(HasSelectedUnit))] + private async Task AddLesson() + { + if (OnAddLesson is null || SelectedUnit is null) return; + if (await OnAddLesson(SelectedUnit.Id, _groupId, KnownMaterials, KnownShorthands)) LoadUnits(); + } + + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] + private async Task EditLesson() + { + if (OnEditLesson is null || SelectedLesson is null) return; + if (await OnEditLesson(SelectedLesson.Model, KnownMaterials, KnownShorthands)) LoadUnits(); + } + + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] + private async Task DeleteLesson() + { + if (OnConfirmDeleteLesson is null || SelectedLesson is null) return; + var lesson = SelectedLesson; + if (!await OnConfirmDeleteLesson(lesson)) return; + _lessons.Delete(lesson.Id); + LoadUnits(); + } + + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] + private async Task MoveLesson() + { + if (OnPickMoveTarget is null || SelectedLesson is null) return; + var lesson = SelectedLesson.Model; + var target = await OnPickMoveTarget(lesson); + if (target is null) return; + MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing); + LoadUnits(); + } + + /// Verschiebt eine Stunde auf ein neues Datum (4.2.4). Ist "Nachrücken" aktiv, verschieben + /// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der + /// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden + /// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert. + private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing) + { + var oldDate = moved.Date; + var delta = newDate.DayNumber - oldDate.DayNumber; + + if (shiftFollowing && delta != 0) + { + foreach (var other in _lessons.GetByUnit(moved.UnitId)) + { + if (other.Id == moved.Id) continue; + if (other.Status != LessonStatus.Planned) continue; + if (other.Date <= oldDate) continue; + other.Date = other.Date.AddDays(delta); + _lessons.Save(other); + } + } + + moved.Date = newDate; + _lessons.Save(moved); + } + + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] + private void AdvanceLessonStatus() + { + if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return; + var lesson = SelectedLesson.Model; + lesson.Status = LessonStatus.Conducted; + _lessons.Save(lesson); + LoadUnits(); + } +} + +// ── Anzeige-DTOs ────────────────────────────────────────────────────────────── + +public class UnitSummary +{ + public Guid Id { get; } + public Unit Model { get; } + public string Title { get; } + public string DateRangeDisplay { get; } + public UnitStatus Status { get; } + public string StatusLabel { get; } + public string StatusColorHex { get; } + public string CompetencyCountLabel { get; } + public int ConductedCount { get; } + public int TotalCount { get; } + public double ProgressFraction { get; } + public string ProgressText { get; } + + public UnitSummary(Unit u, List lessons) + { + Id = u.Id; + Model = u; + Title = u.Title; + DateRangeDisplay = (u.StartDate, u.EndDate) switch + { + ({ } start, { } end) => $"{start:dd.MM.yyyy} – {end:dd.MM.yyyy}", + ({ } start, null) => $"ab {start:dd.MM.yyyy}", + (null, { } end) => $"bis {end:dd.MM.yyyy}", + _ => "–", + }; + Status = u.Status; + StatusLabel = u.Status switch + { + UnitStatus.Planned => "Geplant", + UnitStatus.Active => "Laufend", + UnitStatus.Completed => "Abgeschlossen", + _ => "", + }; + StatusColorHex = u.Status switch + { + UnitStatus.Planned => "#9E9E9E", + UnitStatus.Active => "#FB8C00", + UnitStatus.Completed => "#43A047", + _ => "#9E9E9E", + }; + CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)"; + + TotalCount = lessons.Count; + ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted); + ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount; + ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten"; + } +} + +public class LessonSummary +{ + public Guid Id { get; } + public Lesson Model { get; } + public Guid UnitId { get; } + public string DateDisplay { get; } + public int? LessonNumber { get; } + public string Topic { get; } + public string StartTimeDisplay { get; } + public LessonStatus Status { get; } + public string StatusLabel { get; } + public string StatusColorHex { get; } + public string PhaseCountLabel { get; } + public string TotalDurationLabel { get; } + public string MaterialsDisplay { get; } + public bool HasHomework { get; } + + public LessonSummary(Lesson l) + { + Id = l.Id; + Model = l; + UnitId = l.UnitId; + DateDisplay = l.Date.ToString("dd.MM.yyyy"); + LessonNumber = l.LessonNumber; + Topic = l.Topic; + StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–"; + Status = l.Status; + StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant"; + StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E"; + PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen"; + var totalMinutes = l.Phases.Sum(p => p.DurationMinutes); + TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min."; + MaterialsDisplay = string.Join(", ", l.Phases + .Select(p => p.Material) + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Distinct()); + HasHomework = !string.IsNullOrWhiteSpace(l.Homework); + } +} + +// ── Status-Anzeige (Einheiten/Stunden, analog NiveauDisplay) ────────────────── + +public static class UnitStatusDisplay +{ + public static string[] Options { get; } = ["Geplant", "Laufend", "Abgeschlossen"]; + + public static string ToName(UnitStatus s) => s switch + { + UnitStatus.Active => "Laufend", + UnitStatus.Completed => "Abgeschlossen", + _ => "Geplant", + }; + + public static UnitStatus FromName(string? name) => name switch + { + "Laufend" => UnitStatus.Active, + "Abgeschlossen" => UnitStatus.Completed, + _ => UnitStatus.Planned, + }; +} + +public static class LessonStatusDisplay +{ + public static string[] Options { get; } = ["Geplant", "Durchgeführt"]; + + public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant"; + + public static LessonStatus FromName(string? name) => + name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned; +} + +// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ───────────────────── + +public partial class UnitDialogViewModel : ObservableObject +{ + private readonly IUnitRepository _units; + private readonly ICompetencyDomainRepository _competencyDomains; + private readonly Guid _groupId; + private readonly Guid? _subjectId; + private readonly int _gradeLevel; + private readonly Unit? _editingUnit; + private readonly List _competencyCodes; + + [ObservableProperty] private string _title = ""; + [ObservableProperty] private string _startDateText = ""; + [ObservableProperty] private string _endDateText = ""; + [ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0]; + [ObservableProperty] private string _notes = ""; + [ObservableProperty] private bool _isCompetencyPanelOpen; + [ObservableProperty] private string _titleError = ""; + [ObservableProperty] private string _startDateTextError = ""; + [ObservableProperty] private string _endDateTextError = ""; + + public string[] StatusOptions => UnitStatusDisplay.Options; + public ObservableCollection CompetencyTagGroups { get; } = []; + public string CompetencySummary => _competencyCodes.Count == 0 + ? "Keine Kompetenzen" : $"{_competencyCodes.Count} Kompetenz(en)"; + + /// Fach kommt von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein Fach). + public string SubjectDisplay { get; } + + public Unit? Result { get; private set; } + public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten"; + public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern"; + + public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains, + Guid groupId, Guid? subjectId, int gradeLevel, string subjectName, Unit? editingUnit) + { + _units = units; _competencyDomains = competencyDomains; + _groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel; + _editingUnit = editingUnit; + SubjectDisplay = string.IsNullOrWhiteSpace(subjectName) + ? "Kein Fach hinterlegt (siehe Lerngruppe)" : $"Fach: {subjectName}"; + + _competencyCodes = editingUnit is not null ? [.. editingUnit.Competencies] : []; + BuildCompetencyTagGroups(); + + if (editingUnit is not null) + { + Title = editingUnit.Title; + StartDateText = editingUnit.StartDate?.ToString("dd.MM.yyyy") ?? ""; + EndDateText = editingUnit.EndDate?.ToString("dd.MM.yyyy") ?? ""; + StatusName = UnitStatusDisplay.ToName(editingUnit.Status); + Notes = editingUnit.Notes ?? ""; + } + } + + private void BuildCompetencyTagGroups() + { + CompetencyTagGroups.Clear(); + if (!_subjectId.HasValue) return; + var selected = _competencyCodes.ToHashSet(); + foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel)) + { + var group = new CompetencyTagGroup(domain.Name, domain.Code); + foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) + { + var tag = new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code)); + tag.OnChanged = OnCompetencyToggled; + group.Items.Add(tag); + } + if (group.Items.Count > 0) CompetencyTagGroups.Add(group); + } + } + + private void OnCompetencyToggled(string code, bool selected) + { + if (selected) { if (!_competencyCodes.Contains(code)) _competencyCodes.Add(code); } + else _competencyCodes.Remove(code); + OnPropertyChanged(nameof(CompetencySummary)); + } + + [RelayCommand] private void ToggleCompetencyPanel() => IsCompetencyPanelOpen = !IsCompetencyPanelOpen; + + [RelayCommand] + private void Save() + { + TitleError = ""; StartDateTextError = ""; EndDateTextError = ""; + var valid = true; + + if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } + + DateOnly? startDate = null; + if (!string.IsNullOrWhiteSpace(StartDateText)) + { + if (!DateOnly.TryParseExact(StartDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var s)) + { StartDateTextError = "Format TT.MM.JJJJ."; valid = false; } + else startDate = s; + } + + DateOnly? endDate = null; + if (!string.IsNullOrWhiteSpace(EndDateText)) + { + if (!DateOnly.TryParseExact(EndDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var e)) + { EndDateTextError = "Format TT.MM.JJJJ."; valid = false; } + else endDate = e; + } + + if (valid && startDate is not null && endDate is not null && endDate < startDate) + { EndDateTextError = "Ende darf nicht vor dem Start liegen."; valid = false; } + + if (!valid) return; + + Result = _editingUnit ?? new Unit { GroupId = _groupId }; + Result.Title = Title.Trim(); + Result.StartDate = startDate; + Result.EndDate = endDate; + Result.Status = UnitStatusDisplay.FromName(StatusName); + Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); + Result.Competencies = _competencyCodes; + _units.Save(Result); + } +} + +// ── Dialog: Stunde anlegen / bearbeiten (4.2.2) — tabellarischer Verlaufsplan ──── + +public partial class LessonDialogViewModel : ObservableObject +{ + private readonly ILessonRepository _lessons; + private readonly Guid _unitId; + private readonly Guid _groupId; + private readonly Lesson? _editingLesson; + + [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); + [ObservableProperty] private int? _lessonNumber; + [ObservableProperty] private string _topic = ""; + [ObservableProperty] private string _startTimeText = ""; + [ObservableProperty] private string _homework = ""; + [ObservableProperty] private string _reflection = ""; + [ObservableProperty] private string _statusName = LessonStatusDisplay.Options[0]; + [ObservableProperty] private string _dateTextError = ""; + [ObservableProperty] private string _topicError = ""; + [ObservableProperty] private string _startTimeTextError = ""; + [ObservableProperty] private string _totalDurationDisplay = "0 Minuten gesamt"; + + public string[] StatusOptions => LessonStatusDisplay.Options; + public string[] MaterialSuggestions { get; } + public string[] ShorthandSuggestions { get; } + public ObservableCollection Phases { get; } = []; + + 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) + { + _lessons = lessons; _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson; + MaterialSuggestions = [.. materialSuggestions]; + + // Vorschläge kommen sowohl aus dem gepflegten Kürzel-Katalog (Einstellungen) als auch aus + // bereits in anderen Stunden dieser Gruppe frei getippten Kurzsymbolen — ein Kurzsymbol + // muss nicht vorab im Katalog stehen, um beim nächsten Mal wieder vorgeschlagen zu werden. + var codes = shorthandCodes.GetAll(); + var catalogCodes = (codes.Count > 0 ? codes : DefaultShorthandCodes.All).Select(c => c.Code); + ShorthandSuggestions = catalogCodes.Concat(shorthandHistorySuggestions) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase) + .ToArray(); + + if (editingLesson is not null) + { + DateText = editingLesson.Date.ToString("dd.MM.yyyy"); + LessonNumber = editingLesson.LessonNumber; + Topic = editingLesson.Topic; + StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? ""; + Homework = editingLesson.Homework ?? ""; + Reflection = editingLesson.Reflection ?? ""; + StatusName = LessonStatusDisplay.ToName(editingLesson.Status); + foreach (var p in editingLesson.Phases) AddPhaseInternal(p); + } + RecomputeTimes(); + } + + [RelayCommand] + private void AddPhase() + { + AddPhaseInternal(null); + RecomputeTimes(); + } + + private void AddPhaseInternal(LessonPhaseStep? source) + { + var item = new PhaseStepEditItem + { + Name = source?.Name ?? "", + DurationMinutes = source?.DurationMinutes ?? 5, + Activity = source?.Activity ?? "", + Material = source?.Material ?? "", + Shorthand = source?.Shorthand ?? "", + }; + item.OnChanged = RecomputeTimes; + item.OnRemove = RemovePhase; + item.OnMoveUp = MovePhaseUp; + item.OnMoveDown = MovePhaseDown; + Phases.Add(item); + } + + private void RemovePhase(PhaseStepEditItem item) { Phases.Remove(item); RecomputeTimes(); } + + private void MovePhaseUp(PhaseStepEditItem item) + { + var idx = Phases.IndexOf(item); + if (idx > 0) Phases.Move(idx, idx - 1); + RecomputeTimes(); + } + + private void MovePhaseDown(PhaseStepEditItem item) + { + var idx = Phases.IndexOf(item); + if (idx >= 0 && idx < Phases.Count - 1) Phases.Move(idx, idx + 1); + RecomputeTimes(); + } + + partial void OnStartTimeTextChanged(string value) => RecomputeTimes(); + + /// Dauer ist die primäre Eingabe je Phase; die Uhrzeit wird daraus nur zur Anzeige + /// abgeleitet — kumulativ ab "Beginn", sofern gesetzt (sonst bleibt sie leer). + private void RecomputeTimes() + { + var total = Phases.Sum(p => p.DurationMinutes); + TotalDurationDisplay = $"{total} Minuten gesamt"; + + TimeOnly? cursor = null; + if (!string.IsNullOrWhiteSpace(StartTimeText) && + TimeOnly.TryParseExact(StartTimeText, "HH:mm", null, DateTimeStyles.None, out var start)) + cursor = start; + + foreach (var p in Phases) + { + p.ComputedTimeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : ""; + if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes); + } + } + + [RelayCommand] + private void Save() + { + DateTextError = ""; TopicError = ""; StartTimeTextError = ""; + var valid = true; + + if (string.IsNullOrWhiteSpace(Topic)) { TopicError = "Thema erforderlich."; valid = false; } + + DateOnly date = default; + if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out date)) + { DateTextError = "Format TT.MM.JJJJ."; valid = false; } + + TimeOnly? startTime = null; + if (!string.IsNullOrWhiteSpace(StartTimeText)) + { + if (!TimeOnly.TryParseExact(StartTimeText, "HH:mm", null, DateTimeStyles.None, out var t)) + { StartTimeTextError = "Format HH:MM."; valid = false; } + else startTime = t; + } + + if (!valid) return; + + Result = _editingLesson ?? new Lesson { UnitId = _unitId, GroupId = _groupId }; + Result.UnitId = _unitId; + Result.GroupId = _groupId; + Result.Date = date; + Result.LessonNumber = LessonNumber; + Result.Topic = Topic.Trim(); + Result.StartTime = startTime; + Result.Phases = Phases.Select(p => p.ToModel()).ToList(); + Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim(); + Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim(); + Result.Status = LessonStatusDisplay.FromName(StatusName); + _lessons.Save(Result); + } +} + +// ── Zeile im Verlaufsplan-Editor (4.2.2) ────────────────────────────────────── + +public partial class PhaseStepEditItem : ObservableObject +{ + [ObservableProperty] private string _name = ""; + [ObservableProperty] private int _durationMinutes = 5; + [ObservableProperty] private string _activity = ""; + [ObservableProperty] private string _material = ""; + [ObservableProperty] private string _shorthand = ""; + [ObservableProperty] private string _computedTimeDisplay = ""; + + public Action? OnChanged { get; set; } + public Action? OnRemove { get; set; } + public Action? OnMoveUp { get; set; } + public Action? OnMoveDown { get; set; } + + partial void OnDurationMinutesChanged(int value) => OnChanged?.Invoke(); + + [RelayCommand] private void Remove() => OnRemove?.Invoke(this); + [RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this); + [RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this); + + public LessonPhaseStep ToModel() => new() + { + Name = Name.Trim(), + DurationMinutes = DurationMinutes, + Activity = Activity.Trim(), + Material = Material.Trim(), + Shorthand = Shorthand.Trim(), + }; +} + +// ── Dialog: Stunde verschieben (4.2.4) ──────────────────────────────────────── + +public partial class MoveLessonDialogViewModel : ObservableObject +{ + [ObservableProperty] private string _newDateText; + [ObservableProperty] private bool _shiftFollowingPlanned = true; + [ObservableProperty] private string _newDateTextError = ""; + + public string CurrentDateDisplay { get; } + public MoveLessonTarget? Result { get; private set; } + + public MoveLessonDialogViewModel(DateOnly currentDate) + { + CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy"); + _newDateText = currentDate.ToString("dd.MM.yyyy"); + } + + [RelayCommand] + private void Save() + { + NewDateTextError = ""; + if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) + { + NewDateTextError = "Format TT.MM.JJJJ."; + return; + } + Result = new MoveLessonTarget(date, ShiftFollowingPlanned); + } +} + +// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ──────────── + +public partial class CopyUnitDialogViewModel : ObservableObject +{ + [ObservableProperty] private LearningGroup? _selectedGroup; + [ObservableProperty] private string _newStartDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); + [ObservableProperty] private string _selectedGroupError = ""; + [ObservableProperty] private string _newStartDateTextError = ""; + + public ObservableCollection AvailableGroups { get; } = []; + public CopyUnitTarget? Result { get; private set; } + + public CopyUnitDialogViewModel(IGroupRepository groups, Guid excludeGroupId) + { + foreach (var g in groups.GetAll().Where(g => g.Id != excludeGroupId).OrderBy(g => g.Name)) + AvailableGroups.Add(g); + } + + [RelayCommand] + private void Save() + { + SelectedGroupError = ""; NewStartDateTextError = ""; + var valid = true; + + if (SelectedGroup is null) { SelectedGroupError = "Zielgruppe auswählen."; valid = false; } + + DateOnly anchor = default; + if (!DateOnly.TryParseExact(NewStartDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out anchor)) + { NewStartDateTextError = "Format TT.MM.JJJJ."; valid = false; } + + if (!valid) return; + + Result = new CopyUnitTarget(SelectedGroup!.Id, anchor); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index b3eeb87..26be24b 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -26,6 +26,7 @@ public partial class SettingsViewModel : ObservableObject private readonly PrivacySettingsService _privacy; private readonly IDocumentationRepository _documentation; private readonly IStudentRepository _students; + private readonly IShorthandCodeRepository _shorthandCodes; // ── Fächer ──────────────────────────────────────────────────────────────── @@ -35,6 +36,14 @@ public partial class SettingsViewModel : ObservableObject public ObservableCollection Subjects { get; } = []; + // ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ────────────────────────── + + [ObservableProperty] private string _newShorthandCode = ""; + [ObservableProperty] private string _newShorthandLabel = ""; + [ObservableProperty] private string _newShorthandCodeError = ""; + + public ObservableCollection ShorthandCodes { get; } = []; + // ── Kompetenzkatalog ────────────────────────────────────────────────────── [ObservableProperty] private SubjectListItem? _catalogSubject; @@ -110,7 +119,8 @@ public partial class SettingsViewModel : ObservableObject IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy, - IDocumentationRepository documentation, IStudentRepository students) + IDocumentationRepository documentation, IStudentRepository students, + IShorthandCodeRepository shorthandCodes) { _subjects = subjects; _domainRepo = domainRepo; @@ -124,7 +134,9 @@ public partial class SettingsViewModel : ObservableObject _privacy = privacy; _documentation = documentation; _students = students; + _shorthandCodes = shorthandCodes; LoadSubjects(); + LoadShorthandCodes(); LoadGradingKeyTemplates(); LoadGradingSchemes(); LoadBackups(); @@ -381,6 +393,40 @@ public partial class SettingsViewModel : ObservableObject LoadSubjects(); } + // ── Kürzel-Katalog: Laden / Hinzufügen / Löschen ───────────────────────── + + public void LoadShorthandCodes() + { + ShorthandCodes.Clear(); + foreach (var c in _shorthandCodes.GetAll()) + ShorthandCodes.Add(new ShorthandCodeListItem(c)); + } + + [RelayCommand] + private void AddShorthandCode() + { + if (string.IsNullOrWhiteSpace(NewShorthandCode)) { NewShorthandCodeError = "Kürzel erforderlich."; return; } + try + { + _shorthandCodes.Save(new ShorthandCode { Code = NewShorthandCode.Trim(), Label = NewShorthandLabel.Trim() }); + } + catch (InvalidOperationException ex) + { + NewShorthandCodeError = ex.Message; + return; + } + NewShorthandCode = ""; NewShorthandLabel = ""; NewShorthandCodeError = ""; + LoadShorthandCodes(); + } + + [RelayCommand] + private void DeleteShorthandCode(ShorthandCodeListItem? item) + { + if (item is null) return; + _shorthandCodes.Delete(item.Id); + LoadShorthandCodes(); + } + // ── Katalog: Laden ──────────────────────────────────────────────────────── partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog(); @@ -703,6 +749,13 @@ public class SubjectListItem(Subject s) public string ShortName { get; } = s.ShortName; } +public class ShorthandCodeListItem(ShorthandCode c) +{ + public Guid Id { get; } = c.Id; + public string Code { get; } = c.Code; + public string Label { get; } = c.Label; +} + public class BackupListItem(BackupInfo info) { public string Path { get; } = info.Path; diff --git a/LehrerApp.Desktop/Views/Groups/CopyUnitDialog.axaml b/LehrerApp.Desktop/Views/Groups/CopyUnitDialog.axaml new file mode 100644 index 0000000..d4048f5 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/CopyUnitDialog.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +