Unterrichtsplanung: Einheiten, Verlaufsplan-Editor, Kürzel-Katalog (Kapitel 4.1/4.2)

Neuer Tab "Planung" in GroupDetailView ersetzt den Platzhalter: Einheiten anlegen/bearbeiten/
als Vorlage in andere Gruppe kopieren, Stunden je Einheit mit Verschieben (inkl. Nachrücken
der Folgestunden). Stundeneditor als tabellarischer Verlaufsplan (Phase/Dauer/Tätigkeit/
Material/Kurzsymbol je Zeile, Uhrzeit aus optionalem Stundenbeginn abgeleitet) statt eines
einzelnen Phase-Felds mit Methoden-/Materialien-Chips — Kurzsymbol als Freitext mit
Vorschlägen aus neuem Kürzel-Katalog (Einstellungen) plus bisher verwendeten Werten.
Schema-Migrationen v1-v3 überführen bestehende Daten verlustfrei. 4.2.5 bewusst offen
gelassen (hängt an Stundenplan, Kapitel 4.3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 00:50:57 +02:00
co-authored by Claude Sonnet 5
parent 27359794f7
commit de6ea001e7
28 changed files with 2372 additions and 32 deletions
@@ -157,3 +157,9 @@ public interface ICompetencyDomainRepository
void Delete(Guid id); void Delete(Guid id);
void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel); void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel);
} }
public interface IShorthandCodeRepository
{
List<ShorthandCode> GetAll();
void Save(ShorthandCode code);
void Delete(Guid id);
}
+25 -3
View File
@@ -51,14 +51,36 @@ public class Lesson
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public int? LessonNumber { get; set; } public int? LessonNumber { get; set; }
public string Topic { get; set; } = ""; public string Topic { get; set; } = "";
public string? Phase { get; set; } /// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
public List<string> Methods { get; set; } = []; /// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
public List<string> Materials { get; set; } = []; public TimeOnly? StartTime { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = [];
public string? Homework { get; set; } public string? Homework { get; set; }
public string? Reflection { get; set; } public string? Reflection { get; set; }
public LessonStatus Status { get; set; } = LessonStatus.Planned; public LessonStatus Status { get; set; } = LessonStatus.Planned;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>
/// Eine Zeile im tabellarischen Stundenverlaufsplan (Phase/Dauer/Tätigkeit/Material/Kurzsymbol).
/// <see cref="DurationMinutes"/> ist die primäre Eingabe (man plant in Zeitblöcken); die Uhrzeit
/// je Phase wird im Editor daraus abgeleitet, wenn <see cref="Lesson.StartTime"/> gesetzt ist.
/// <see cref="Shorthand"/> 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 (<see cref="ShorthandCode"/>) als auch aus bereits in anderen Stunden
/// verwendeten Werten.
/// </summary>
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 UnitStatus { Planned, Active, Completed }
public enum LessonStatus { Planned, Conducted } public enum LessonStatus { Planned, Conducted }
+29
View File
@@ -0,0 +1,29 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// 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
/// <see cref="LessonPhaseStep.Shorthand"/> angeboten.
/// </summary>
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 <see cref="DefaultParticipationAspects"/>.
public static class DefaultShorthandCodes
{
public static readonly IReadOnlyList<ShorthandCode> 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" },
];
}
+91 -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(1, context.SchemaVersion); Assert.Equal(3, context.SchemaVersion);
} }
[Fact] [Fact]
@@ -132,7 +132,96 @@ public sealed class LiteDbContextTests
using var second = new LiteDbContext(temp.Path); 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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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 private sealed class TempDatabase : IDisposable
+64
View File
@@ -424,4 +424,68 @@ public sealed class RepositoryTests
Assert.Null(db.Documentation.FindById(doc.Id)); Assert.Null(db.Documentation.FindById(doc.Id));
Assert.Null(storage.OpenRead(attachmentId)); 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<InvalidOperationException>(() =>
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());
}
} }
+86 -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 = 1; private const int CurrentSchemaVersion = 3;
private readonly LiteDatabase _db; private readonly LiteDatabase _db;
@@ -55,6 +55,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections"); public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections");
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 void Checkpoint() => _db.Checkpoint(); public void Checkpoint() => _db.Checkpoint();
@@ -83,6 +84,16 @@ public class LiteDbContext : IDisposable
MigrateExistingData(); MigrateExistingData();
version = 1; version = 1;
} }
if (version < 2)
{
MigrateLessonPhases();
version = 2;
}
if (version < 3)
{
MigrateLessonShorthand();
version = 3;
}
WriteSchemaVersion(version); 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 <see cref="LessonPhaseStep"/>-Zeile der
/// neuen Verlaufsplan-Tabelle zusammen. Nur die alten Felder werden gelesen — die typisierte
/// <see cref="Lesson"/>-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<BsonDocument>("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 (<c>ShorthandFrom</c>/
/// <c>ShorthandTo</c>) 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<BsonDocument>("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() private void RemoveRedundantLegacyFields()
{ {
RemoveFields("exams", "Subject"); RemoveFields("exams", "Subject");
@@ -237,6 +321,7 @@ public class LiteDbContext : IDisposable
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true); Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
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);
} }
public void Dispose() => _db.Dispose(); public void Dispose() => _db.Dispose();
+19 -1
View File
@@ -211,7 +211,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
public class LessonRepository(LiteDbContext db) : ILessonRepository public class LessonRepository(LiteDbContext db) : ILessonRepository
{ {
public List<Lesson> GetByUnit(Guid id) => public List<Lesson> 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<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) => public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList(); db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList();
public List<Lesson> GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) => public List<Lesson> 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<ShorthandCode> 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 class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{ {
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+50
View File
@@ -148,6 +148,56 @@ public class FakeAttachmentStorage : IAttachmentStorage
public void Delete(string storageId) => _blobs.Remove(storageId); public void Delete(string storageId) => _blobs.Remove(storageId);
} }
public class FakeUnits : IUnitRepository
{
private readonly List<Unit> _all = [];
public void Add(Unit u) => _all.Add(u);
public Unit? GetById(Guid id) => _all.FirstOrDefault(u => u.Id == id);
public List<Unit> 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<Lesson> _all = [];
public void Add(Lesson l) => _all.Add(l);
public List<Lesson> GetByUnit(Guid unitId) =>
_all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
_all.Where(l => l.GroupId == groupId && l.Date == date).ToList();
public List<Lesson> 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<Subject> all) : ISubjectRepository
{
public List<Subject> 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<CompetencyDomain> 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<ShorthandCode> all) : IShorthandCodeRepository
{
public List<ShorthandCode> GetAll() => all;
public void Save(ShorthandCode code) { }
public void Delete(Guid id) { }
}
public class FakeReportGrades : IReportGradeRepository public class FakeReportGrades : IReportGradeRepository
{ {
private readonly List<ReportGrade> _all = []; private readonly List<ReportGrade> _all = [];
@@ -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));
}
}
@@ -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<MoveLessonTarget?>(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<MoveLessonTarget?>(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<CopyUnitTarget?>(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);
}
}
+2
View File
@@ -127,6 +127,7 @@ public static class AppBootstrapper
services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>(); services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>();
services.AddSingleton<ISubjectRepository, SubjectRepository>(); services.AddSingleton<ISubjectRepository, SubjectRepository>();
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>(); services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>();
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>(); services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
@@ -182,6 +183,7 @@ public static class AppBootstrapper
services.AddTransient<StudentDetailViewModel>(); services.AddTransient<StudentDetailViewModel>();
services.AddTransient<ParticipationTabViewModel>(); services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>(); services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<PlanningTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
@@ -183,6 +183,7 @@ public partial class GroupDetailViewModel : ObservableObject
public ParticipationTabViewModel ParticipationTab { get; } public ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; } public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; } public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; } public Func<Guid, Task<bool>>? OnAddExam { get; set; }
public Func<Exam, Task<bool>>? OnEditExam { get; set; } public Func<Exam, Task<bool>>? OnEditExam { get; set; }
@@ -194,12 +195,14 @@ public partial class GroupDetailViewModel : ObservableObject
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects, IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades, IExamRepository exams, IGradeRepository grades,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab) ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab)
{ {
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades; _exams = exams; _grades = grades;
ParticipationTab = participationTab; ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab; GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab;
} }
public void LoadGroup(Guid id) public void LoadGroup(Guid id)
@@ -217,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject
ReloadExams(); ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear); ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle); GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
PlanningTab.Initialize(Group.Id);
} }
private void ReloadExams() private void ReloadExams()
@@ -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<UnitSummary> Units { get; } = [];
public ObservableCollection<LessonSummary> 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<string> KnownMaterials { get; private set; } = [];
public List<string> KnownShorthands { get; private set; } = [];
public Func<Guid, Task<bool>>? OnAddUnit { get; set; }
public Func<Unit, Task<bool>>? OnEditUnit { get; set; }
public Func<UnitSummary, Task<bool>>? OnConfirmDeleteUnit { get; set; }
public Func<Unit, Task<CopyUnitTarget?>>? OnPickCopyTarget { get; set; }
public Func<Guid, Guid, List<string>, List<string>, Task<bool>>? OnAddLesson { get; set; }
public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; }
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
public Func<Lesson, Task<MoveLessonTarget?>>? 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<string>();
var shorthands = new HashSet<string>();
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<Lesson> 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<Lesson> 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<string> _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<CompetencyTagGroup> 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<PhaseStepEditItem> 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<string> materialSuggestions, List<string> 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<PhaseStepEditItem>? OnRemove { get; set; }
public Action<PhaseStepEditItem>? OnMoveUp { get; set; }
public Action<PhaseStepEditItem>? 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<LearningGroup> 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);
}
}
@@ -26,6 +26,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly PrivacySettingsService _privacy; private readonly PrivacySettingsService _privacy;
private readonly IDocumentationRepository _documentation; private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes;
// ── Fächer ──────────────────────────────────────────────────────────────── // ── Fächer ────────────────────────────────────────────────────────────────
@@ -35,6 +36,14 @@ public partial class SettingsViewModel : ObservableObject
public ObservableCollection<SubjectListItem> Subjects { get; } = []; public ObservableCollection<SubjectListItem> Subjects { get; } = [];
// ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ──────────────────────────
[ObservableProperty] private string _newShorthandCode = "";
[ObservableProperty] private string _newShorthandLabel = "";
[ObservableProperty] private string _newShorthandCodeError = "";
public ObservableCollection<ShorthandCodeListItem> ShorthandCodes { get; } = [];
// ── Kompetenzkatalog ────────────────────────────────────────────────────── // ── Kompetenzkatalog ──────────────────────────────────────────────────────
[ObservableProperty] private SubjectListItem? _catalogSubject; [ObservableProperty] private SubjectListItem? _catalogSubject;
@@ -110,7 +119,8 @@ public partial class SettingsViewModel : ObservableObject
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy, AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
IDocumentationRepository documentation, IStudentRepository students) IDocumentationRepository documentation, IStudentRepository students,
IShorthandCodeRepository shorthandCodes)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
@@ -124,7 +134,9 @@ public partial class SettingsViewModel : ObservableObject
_privacy = privacy; _privacy = privacy;
_documentation = documentation; _documentation = documentation;
_students = students; _students = students;
_shorthandCodes = shorthandCodes;
LoadSubjects(); LoadSubjects();
LoadShorthandCodes();
LoadGradingKeyTemplates(); LoadGradingKeyTemplates();
LoadGradingSchemes(); LoadGradingSchemes();
LoadBackups(); LoadBackups();
@@ -381,6 +393,40 @@ public partial class SettingsViewModel : ObservableObject
LoadSubjects(); 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 ──────────────────────────────────────────────────────── // ── Katalog: Laden ────────────────────────────────────────────────────────
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog(); partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
@@ -703,6 +749,13 @@ public class SubjectListItem(Subject s)
public string ShortName { get; } = s.ShortName; 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 class BackupListItem(BackupInfo info)
{ {
public string Path { get; } = info.Path; public string Path { get; } = info.Path;
@@ -0,0 +1,44 @@
<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.CopyUnitDialog"
x:DataType="vm:CopyUnitDialogViewModel"
Title="Einheit als Vorlage kopieren"
Width="440" Height="320" MinWidth="380" MinHeight="280"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Einheit als Vorlage kopieren" Classes="dialogtitle"/>
<TextBlock Text="Titel, Aufbau und die zeitlichen Abstände der Stunden zueinander werden übernommen, alle Kalendertage neu ab dem gewählten Startdatum berechnet."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<StackPanel Spacing="4">
<TextBlock Text="Zielgruppe *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding AvailableGroups}" SelectedItem="{Binding SelectedGroup}"
HorizontalAlignment="Stretch" PlaceholderText="Gruppe auswählen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:LearningGroup">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{Binding SelectedGroupError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SelectedGroupError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Neues Startdatum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewStartDateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding NewStartDateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding NewStartDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Kopieren" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class CopyUnitDialog : Window
{
public CopyUnitDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is CopyUnitDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -154,12 +154,7 @@
<!-- Tab: Planung --> <!-- Tab: Planung -->
<ContentPage Header="Planung"> <ContentPage Header="Planung">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> <views:PlanningTabView DataContext="{Binding PlanningTab}"/>
<TextBlock Text="Unterrichtseinheiten" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage> </ContentPage>
<!-- Tab: Dokumentation --> <!-- Tab: Dokumentation -->
@@ -0,0 +1,130 @@
<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.LessonDialog"
x:DataType="vm:LessonDialogViewModel"
Title="{Binding DialogTitle}"
Width="960" Height="760" MinWidth="720" MinHeight="480"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14" Margin="0,0,12,0">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<Grid ColumnDefinitions="*,12,*,12,*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Stunde Nr." FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding LessonNumber}" Minimum="1" Maximum="20" FormatString="0"
ShowButtonSpinner="False"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Text="Beginn" FontSize="12" Opacity="0.7"
ToolTip.Tip="Optional. Solange es noch keinen Stundenplan gibt: von Hand eintragen, damit die Uhrzeiten je Phase unten abgeleitet werden können."/>
<TextBox Text="{Binding StartTimeText}" PlaceholderText="HH:MM"/>
<TextBlock Text="{Binding StartTimeTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StartTimeTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="6" Spacing="4">
<TextBlock Text="Status" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding StatusName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Thema *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Topic}" PlaceholderText="z.B. Brechung an planparallelen Platten"/>
<TextBlock Text="{Binding TopicError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TopicError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="Verlaufsplan" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding TotalDurationDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content=" Phase" Command="{Binding AddPhaseCommand}"/>
</Grid>
<!-- Tabellenkopf -->
<Grid ColumnDefinitions="140,130,*,110,100,26,26,26" Margin="4,0,0,0">
<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="2" Text="Tätigkeit" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Material" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Kurzsymbol" FontSize="11" FontWeight="SemiBold" Opacity="0.6"/>
</Grid>
<Separator/>
<!-- Eine Zeile je Phase, echte Tabellenspalten nebeneinander statt gestapelter Karten. -->
<ItemsControl ItemsSource="{Binding Phases}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PhaseStepEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<Grid ColumnDefinitions="140,130,*,110,100,26,26,26">
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="z.B. Erarbeitung"
VerticalAlignment="Top" Margin="0,0,6,0"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
VerticalAlignment="Top" Margin="0,0,6,0">
<NumericUpDown Value="{Binding DurationMinutes}" Minimum="0" Maximum="180" Width="62"
FormatString="0" ShowButtonSpinner="False" ToolTip.Tip="Dauer in Minuten"/>
<TextBlock Text="{Binding ComputedTimeDisplay}" FontSize="12" Opacity="0.6"
VerticalAlignment="Center"/>
</StackPanel>
<TextBox Grid.Column="2" Text="{Binding Activity}" AcceptsReturn="True" TextWrapping="Wrap"
Height="56" VerticalAlignment="Top" Margin="0,0,6,0"
PlaceholderText="Lehrer-/Schüler-Tätigkeit"
ToolTip.Tip="Bei mehr Text scrollt das Feld intern."/>
<AutoCompleteBox Grid.Column="3" Text="{Binding Material}"
ItemsSource="{Binding $parent[ItemsControl].((vm:LessonDialogViewModel)DataContext).MaterialSuggestions}"
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
Margin="0,0,6,0" PlaceholderText="z.B. AB01"/>
<AutoCompleteBox Grid.Column="4" Text="{Binding Shorthand}"
ItemsSource="{Binding $parent[ItemsControl].((vm:LessonDialogViewModel)DataContext).ShorthandSuggestions}"
FilterMode="Contains" MinimumPrefixLength="0" VerticalAlignment="Top"
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)."/>
<Button Grid.Column="5" Content="↑" Command="{Binding MoveUpCommand}" Padding="4,2"
VerticalAlignment="Top" ToolTip.Tip="Nach oben"/>
<Button Grid.Column="6" Content="↓" Command="{Binding MoveDownCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Nach unten"/>
<Button Grid.Column="7" Content="✕" Command="{Binding RemoveCommand}" Padding="4,2"
VerticalAlignment="Top" Margin="2,0,0,0" ToolTip.Tip="Entfernen"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBlock Text="Hausaufgabe" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Homework}" AcceptsReturn="True" Height="48" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Reflexion" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Reflection}" AcceptsReturn="True" Height="56" TextWrapping="Wrap"
PlaceholderText="Nach der Stunde: was lief gut, was nicht?"/>
</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="{Binding SaveButtonText}"
HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class LessonDialog : Window
{
public LessonDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is LessonDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,35 @@
<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.MoveLessonDialog"
x:DataType="vm:MoveLessonDialogViewModel"
Title="Stunde verschieben"
Width="400" Height="260" MinWidth="360" MinHeight="240"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Stunde verschieben" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.6">
<Run Text="Bisheriges Datum: "/>
<Run Text="{Binding CurrentDateDisplay}"/>
</TextBlock>
<StackPanel Spacing="4">
<TextBlock Text="Neues Datum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding NewDateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding NewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<CheckBox Content="Folgestunden automatisch verschieben"
IsChecked="{Binding ShiftFollowingPlanned}"
ToolTip.Tip="Verschiebt alle noch geplanten Stunden derselben Einheit, die nach dieser Stunde liegen, um denselben Zeitraum. Bereits durchgeführte Stunden bleiben unverändert."/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Verschieben" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class MoveLessonDialog : Window
{
public MoveLessonDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is MoveLessonDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,108 @@
<UserControl 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.PlanningTabView"
x:DataType="vm:PlanningTabViewModel">
<Grid RowDefinitions="Auto,2*,Auto,Auto,2*" Margin="16">
<!-- Einheiten-Toolbar -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
<TextBlock Grid.Column="0" Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content=" Einheit" Command="{Binding AddUnitCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}"/>
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}"/>
</StackPanel>
</Grid>
<!-- Einheiten-Tabelle -->
<DataGrid Grid.Row="1" ItemsSource="{Binding Units}"
SelectedItem="{Binding SelectedUnit}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False"
Margin="0,0,0,8">
<DataGrid.Columns>
<DataGridTextColumn Header="Titel" Binding="{Binding Title}" Width="*"/>
<DataGridTextColumn Header="Zeitraum" Binding="{Binding DateRangeDisplay}" Width="200"/>
<DataGridTemplateColumn Header="Status" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:UnitSummary">
<Border Background="{Binding StatusColorHex}" CornerRadius="4"
Padding="8,2" HorizontalAlignment="Left">
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Fortschritt" Width="180">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:UnitSummary">
<StackPanel Spacing="2" VerticalAlignment="Center" Margin="0,4">
<ProgressBar Value="{Binding ProgressFraction}" Maximum="1" Height="6"/>
<TextBlock Text="{Binding ProgressText}" FontSize="11" Opacity="0.7"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Kompetenzen" Binding="{Binding CompetencyCountLabel}" Width="120"/>
</DataGrid.Columns>
</DataGrid>
<Separator Grid.Row="2" Margin="0,0,0,8"/>
<!-- Stunden-Toolbar -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Stunden" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content=" Stunde" Command="{Binding AddLessonCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}"/>
</StackPanel>
</Grid>
<!-- Stunden-Tabelle -->
<DataGrid Grid.Row="4" ItemsSource="{Binding Lessons}"
SelectedItem="{Binding SelectedLesson}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding DateDisplay}" Width="100"/>
<DataGridTextColumn Header="Nr." Binding="{Binding LessonNumber}" Width="50"/>
<DataGridTextColumn Header="Thema" Binding="{Binding Topic}" Width="*"/>
<DataGridTextColumn Header="Beginn" Binding="{Binding StartTimeDisplay}" Width="70"/>
<DataGridTemplateColumn Header="Status" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:LessonSummary">
<Border Background="{Binding StatusColorHex}" CornerRadius="4"
Padding="8,2" HorizontalAlignment="Left">
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Phasen" Binding="{Binding PhaseCountLabel}" Width="80"/>
<DataGridTextColumn Header="Dauer" Binding="{Binding TotalDurationLabel}" Width="80"/>
<DataGridTextColumn Header="Materialien" Binding="{Binding MaterialsDisplay}" Width="150"/>
<DataGridTemplateColumn Header="HA" Width="40">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:LessonSummary">
<TextBlock Text="✓" IsVisible="{Binding HasHomework}" HorizontalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>
@@ -0,0 +1,114 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class PlanningTabView : UserControl
{
public PlanningTabView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is PlanningTabViewModel vm)
{
vm.OnAddUnit = groupId => ShowUnitDialog(groupId, editingUnit: null);
vm.OnEditUnit = unit => ShowUnitDialog(unit.GroupId, editingUnit: unit);
vm.OnConfirmDeleteUnit = ShowDeleteUnitDialog;
vm.OnPickCopyTarget = ShowCopyUnitDialog;
vm.OnAddLesson = (unitId, groupId, materials, shorthands) =>
ShowLessonDialog(unitId, groupId, materials, shorthands, editingLesson: null);
vm.OnEditLesson = (lesson, materials, shorthands) =>
ShowLessonDialog(lesson.UnitId, lesson.GroupId, materials, shorthands, editingLesson: lesson);
vm.OnConfirmDeleteLesson = ShowDeleteLessonDialog;
vm.OnPickMoveTarget = ShowMoveLessonDialog;
}
}
private async Task<bool> ShowUnitDialog(Guid groupId, Unit? editingUnit)
{
if (DataContext is not PlanningTabViewModel vm) return false;
var dialogVm = new UnitDialogViewModel(
App.Services.GetRequiredService<IUnitRepository>(),
App.Services.GetRequiredService<ICompetencyDomainRepository>(),
groupId, vm.SubjectId, vm.GradeLevel, vm.SubjectName, editingUnit);
var dialog = new UnitDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var ok = await dialog.ShowDialog<bool>(owner);
return ok && dialogVm.Result is not null;
}
private async Task<bool> ShowDeleteUnitDialog(UnitSummary unit)
{
var info = new ConfirmDialogInfo
{
Title = "Einheit löschen?",
Message = $"\"{unit.Title}\" wird inkl. aller zugehörigen Stunden endgültig gelöscht.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<CopyUnitTarget?> ShowCopyUnitDialog(Unit unit)
{
var dialogVm = new CopyUnitDialogViewModel(
App.Services.GetRequiredService<IGroupRepository>(), unit.GroupId);
var dialog = new CopyUnitDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var ok = await dialog.ShowDialog<bool>(owner);
return ok ? dialogVm.Result : null;
}
private async Task<bool> ShowLessonDialog(Guid unitId, Guid groupId,
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
{
var dialogVm = new LessonDialogViewModel(
App.Services.GetRequiredService<ILessonRepository>(),
App.Services.GetRequiredService<IShorthandCodeRepository>(),
unitId, groupId, materialSuggestions, shorthandHistorySuggestions, editingLesson);
var dialog = new LessonDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var ok = await dialog.ShowDialog<bool>(owner);
return ok && dialogVm.Result is not null;
}
private async Task<bool> ShowDeleteLessonDialog(LessonSummary lesson)
{
var info = new ConfirmDialogInfo
{
Title = "Stunde löschen?",
Message = $"Die Stunde vom {lesson.DateDisplay} wird endgültig gelöscht.",
ConfirmText = "Löschen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<MoveLessonTarget?> ShowMoveLessonDialog(Lesson lesson)
{
var dialogVm = new MoveLessonDialogViewModel(lesson.Date);
var dialog = new MoveLessonDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var ok = await dialog.ShowDialog<bool>(owner);
return ok ? dialogVm.Result : null;
}
}
@@ -0,0 +1,87 @@
<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.UnitDialog"
x:DataType="vm:UnitDialogViewModel"
Title="{Binding DialogTitle}"
Width="520" Height="600" MinWidth="440" MinHeight="380"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14" Margin="0,0,12,0">
<StackPanel Spacing="2">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<TextBlock Text="{Binding SubjectDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}" PlaceholderText="z.B. Optik: Reflexion und Brechung"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Start" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding StartDateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding StartDateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StartDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Ende" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding EndDateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding EndDateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding EndDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Status" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding StatusName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Notizen" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Notes}" AcceptsReturn="True" Height="56" TextWrapping="Wrap"/>
</StackPanel>
<Separator Margin="0,4"/>
<Button Content="{Binding CompetencySummary}" Command="{Binding ToggleCompetencyPanelCommand}"
HorizontalAlignment="Left" FontSize="12" Padding="8,4"/>
<ItemsControl ItemsSource="{Binding CompetencyTagGroups}" IsVisible="{Binding IsCompetencyPanelOpen}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CompetencyTagGroup">
<StackPanel Margin="4,2">
<TextBlock Text="{Binding DisplayName}" FontSize="11" Opacity="0.6"/>
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CompetencyTag">
<ToggleButton Content="{Binding Display}" IsChecked="{Binding IsSelected}"
Margin="0,2,6,2" FontSize="11" Padding="6,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</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="{Binding SaveButtonText}"
HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class UnitDialog : Window
{
public UnitDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is UnitDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -56,6 +56,54 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Kürzel-Katalog -->
<ContentPage Header="Kürzel-Katalog">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<TextBlock Text="Kürzel für den Stundenverlaufsplan (Kapitel 4.2), z.B. Tb = Tafelbild. Im Stundeneditor werden Von/Nach aus diesen Kürzeln zu einem Kurzsymbol wie &quot;Tb-&gt;SH&quot; kombiniert."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding ShorthandCodes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ShorthandCodeListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="80,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Code}"
VerticalAlignment="Center" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding Label}"
VerticalAlignment="Center" Opacity="0.7" FontSize="13"/>
<Button Grid.Column="2" Content="Löschen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteShorthandCodeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="10">
<TextBlock Text="Neues Kürzel" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<Grid ColumnDefinitions="100,12,*,12,Auto">
<TextBox Grid.Column="0" Text="{Binding NewShorthandCode}"
PlaceholderText="Kürzel (z.B. Tb)"/>
<TextBox Grid.Column="2" Text="{Binding NewShorthandLabel}"
PlaceholderText="Bedeutung (z.B. Tafelbild)"/>
<Button Grid.Column="4" Content="Hinzufügen"
Command="{Binding AddShorthandCodeCommand}"/>
</Grid>
<TextBlock Text="{Binding NewShorthandCodeError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewShorthandCodeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Kompetenzen --> <!-- Tab: Kompetenzen -->
<ContentPage Header="Kompetenzen"> <ContentPage Header="Kompetenzen">
<ScrollViewer> <ScrollViewer>
+98 -17
View File
@@ -224,25 +224,102 @@ zusammenziehen, daraus die Halbjahresnote bilden":
## 4. Unterrichtsplanung ## 4. Unterrichtsplanung
Modelle `Unit` und `Lesson` existieren, `UnitRepository`/`LessonRepository` ebenfalls. Modelle `Unit` und `Lesson` existieren, `UnitRepository`/`LessonRepository` ebenfalls.
Navigationspunkt "Unterrichtsplanung" ist ein `PlaceholderViewModel` Navigationspunkt "Unterrichtsplanung" ist weiterhin ein `PlaceholderViewModel`
([MainWindowViewModel.cs:41](LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs#L41)). ([MainWindowViewModel.cs](LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs)) — er bleibt
bewusst Platzhalter, bis die gruppenübergreifende Kalenderübersicht aus 4.4 steht. 4.1/4.2 leben
stattdessen im gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
### 4.1 Unterrichtseinheiten (`Unit`) ### 4.1 Unterrichtseinheiten (`Unit`)
- [ ] **4.1.1** Listenansicht der Einheiten je Gruppe mit Status und Zeitraum - [x] **4.1.1** Listenansicht der Einheiten je Gruppe mit Status und Zeitraum
(ersetzt den Platzhalter im Tab "Planung"). (ersetzt den Platzhalter im Tab "Planung").
- [ ] **4.1.2** Dialog Einheit anlegen/bearbeiten: Titel, Fach, Zeitraum, Status, Notizen. - [x] **4.1.2** Dialog Einheit anlegen/bearbeiten: Titel, Fach, Zeitraum, Status, Notizen.
- [ ] **4.1.3** Kompetenzen aus dem Katalog (siehe 8) einer Einheit zuordnen — Mehrfachauswahl. - [x] **4.1.3** Kompetenzen aus dem Katalog (siehe 8) einer Einheit zuordnen — Mehrfachauswahl.
- [ ] **4.1.4** Einheit als Vorlage speichern und in eine andere Gruppe kopieren - [x] **4.1.4** Einheit als Vorlage speichern und in eine andere Gruppe kopieren
(inkl. Stunden, ohne Datumsbezug). (inkl. Stunden, ohne Datumsbezug).
- [ ] **4.1.5** Fortschrittsanzeige: gehaltene / geplante Stunden der Einheit. - [x] **4.1.5** Fortschrittsanzeige: gehaltene / geplante Stunden der Einheit.
### 4.2 Einzelstunden (`Lesson`) ### 4.2 Einzelstunden (`Lesson`)
- [ ] **4.2.1** Stundenliste innerhalb einer Einheit, sortiert nach Datum/Stundennummer. - [x] **4.2.1** Stundenliste innerhalb einer Einheit, sortiert nach Datum/Stundennummer.
- [ ] **4.2.2** Stundeneditor: Thema, Phase, Methoden, Materialien, Hausaufgabe. - [x] **4.2.2** Stundeneditor als tabellarischer Verlaufsplan (Vorbild: vom Nutzer bereitgestelltes
Methoden/Materialien als Chips mit Autovervollständigung aus bisherigen Einträgen. Beispiel eines realen Stundenverlaufsplans) statt einzelnem Phase-Textfeld + Methoden-/
- [ ] **4.2.3** Status `Planned → Conducted` setzen, Reflexionsfeld nach der Stunde. Materialien-Chips: mehrere Phasen-Zeilen (Einstieg, Erarbeitung 1, Sicherung 1, ...), je
- [ ] **4.2.4** Stunden verschieben (z.B. bei Ausfall) — Folgestunden automatisch nachrücken. Zeile Name, Dauer (Minuten), Tätigkeit, Material (mit Autovervollständigung aus bisherigen
- [ ] **4.2.5** Stunden serienweise aus dem Stundenplan (4.3) erzeugen. Einträgen) und ein Kurzsymbol, das den Materialfluss kodiert (z.B. `Tb->SH` = Tafelbild wird
ins Schülerheft übertragen).
- [x] **4.2.3** Status `Planned → Conducted` setzen, Reflexionsfeld nach der Stunde.
- [x] **4.2.4** Stunden verschieben (z.B. bei Ausfall) — Folgestunden automatisch nachrücken.
- [ ] **4.2.5** Stunden serienweise aus dem Stundenplan (4.3) erzeugen. Bewusst nicht mit einem
Ersatz-Mechanismus vorgezogen — hängt an 4.3 (Wochentag/Stunden-Muster aus dem Stundenplan),
ein selbstgebautes "N Wochenstunden anlegen" wäre nur Mehrarbeit, die 4.3 später doppelt.
Einzige Möglichkeit, Stunden anzulegen, bleibt vorerst der manuelle " Stunde"-Dialog (4.2.2).
Umgesetzt über den neuen Tab "Planung" in
[GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml)
(ersetzt den bisherigen Platzhalter), analog zu Mitarbeit/Noten als eigenes UserControl
[PlanningTabView.axaml](LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml) mit eigenem
[PlanningTabViewModel](LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs)
(Einheiten-Tabelle oben, Stunden der ausgewählten Einheit darunter). Modelle, Repositories und
DI-Registrierung existierten bereits; neu sind nur die UI-Schicht sowie zwei Verhaltensfragen:
- **Fach** wird wie bei `Exam` nicht auf `Unit` gespeichert, sondern read-only aus
`LearningGroup.SubjectId` anzeigt (jede Gruppe unterrichtet ein Fach).
- **Verschieben mit Nachrücken (4.2.4):** `MoveLessonDialog` fragt neues Datum + Checkbox
"Folgestunden automatisch verschieben" (Standard an) ab. Verschoben werden dabei nur noch
geplante (`Planned`) Stunden derselben Einheit, die ursprünglich nach der verschobenen Stunde
lagen; bereits durchgeführte (`Conducted`) Stunden bleiben unangetastet.
- **Vorlage-Kopie (4.1.4):** `CopyUnitDialog` fragt Zielgruppe + neues Startdatum ab. "Ohne
Datumsbezug" bedeutet konkret: die relativen Tages-Abstände der Stunden zueinander bleiben
erhalten, werden aber auf das neue Startdatum re-verankert statt die alten Kalendertage zu
übernehmen. Reflexion wird geleert, Status auf "Geplant" zurückgesetzt, `Lesson.GroupId` wird
auf jeder Kopie explizit auf die Zielgruppe gesetzt (siehe
[Datenmodell.md](docs/Datenmodell.md), Abschnitt "Bewusste Denormalisierung").
`LessonRepository.GetByUnit` sortiert jetzt zusätzlich nach `LessonNumber` bei gleichem Datum
(Doppelstunden am selben Tag).
**Nachtrag zu 4.2.2 (Verlaufsplan-Redesign):** `Lesson.Phase`/`Methods`/`Materials` wurden durch
`Lesson.Phases: List<LessonPhaseStep>` (Name/Dauer/Tätigkeit/Material/Kurzsymbol je Zeile) sowie
ein optionales `Lesson.StartTime` ersetzt — Modelle, Migration und Editor in
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
[LiteDbContext.cs](LehrerApp.Data/LiteDbContext.cs) (Schema-Version 3,
`MigrateLessonPhases()` fasst alte Lessons verlustfrei in eine synthetisierte Phasen-Zeile
zusammen) und [PlanningViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs).
Design-Entscheidungen:
- **Dauer ist die primäre Eingabe** je Phase (man plant in Zeitblöcken); die Uhrzeit je Phase wird
im Editor nur zur Anzeige daraus abgeleitet (`LessonDialogViewModel.RecomputeTimes()`), sofern
das optionale `StartTime`-Feld ("Beginn") gesetzt ist. Der Stundenplan (4.3) existiert noch
nicht — bis dahin wird `StartTime` manuell gepflegt, könnte später automatisch befüllt werden.
- **Kurzsymbol** (`LessonPhaseStep.Shorthand`) ist bewusst ein einzelnes Freitextfeld statt einer
erzwungenen Von/Nach-Struktur (Nutzer-Feedback: manchmal ein Materialfluss-Pfeil wie "AB001->S",
manchmal nur eine Sozialform ohne Pfeil wie "Plenum" oder "LDE"). Vorschläge kombinieren den in
den Einstellungen gepflegten Kürzel-Katalog (neues Modell `ShorthandCode`, CRUD-Muster 1:1 von
der Fach-Verwaltung übernommen; Startwerte `DefaultShorthandCodes` L/S/Tb/SH/AB/GA, solange der
Katalog leer ist — kein DB-Seed) mit bereits in anderen Stunden der Gruppe frei getippten Werten
(`PlanningTabViewModel.KnownShorthands`, analog zur Material-Autovervollständigung) — ein
Kurzsymbol muss also nicht vorab im Katalog stehen, um beim nächsten Mal wieder vorgeschlagen zu
werden.
- **Tabellenlayout statt Karten** (Nutzer-Feedback: gestapelte Bordered-Cards pro Phase wirkten
träge): der Verlaufsplan-Editor zeigt eine Kopfzeile mit Spaltentiteln und je Phase eine flache,
einzeilige Zeile mit denselben Spaltenbreiten (Name/Dauer/Zeit/Tätigkeit/Material/Kurzsymbol/
Auf-Ab-Entfernen) statt mehrerer intern gestapelter Unterzeilen — nur die Tätigkeit wächst bei
Bedarf mehrzeilig, alles andere bleibt einzeilig nebeneinander wie in der realen Vorlage.
- Die Migration liest die alte Struktur bewusst über rohe `BsonDocument`s statt über die
typisierte `Lessons`-Collection, da die alten Felder nach der Modelländerung beim typisierten
Deserialisieren sonst bereits verworfen wären, bevor sie gelesen werden können.
**Ideensammlung "Live-Unterrichtsmodus" (noch nicht geplant, nicht Teil von 4.2):** beim
Besprechen des Verlaufsplan-Redesigns kamen weitergehende Wünsche auf, die bewusst zurückgestellt
wurden, da sie eigene Datenmodelle (Live-Session-Zustand, Phasen-Verschiebung zwischen Stunden)
brauchen:
- Fortschrittslinie/Zeitanzeige während des Haltens der Stunde, die zeigt, wo man gerade stehen
müsste.
- "Fertig"/"Überziehen" je Phase anklickbar, nachfolgende Phasenzeiten passen sich automatisch an;
Visualisierung, wie weit man dem Plan hinterherhängt.
- Hinterlegte alternative Stundenenden, in die man bei Zeitnot direkt hineinspringen kann.
- "Phase in nächste Stunde schieben"-Aktion, mit Hinweis-Symbol an der Folgestunde, dass dort noch
offene Phasen/Arbeitsaufträge der Vorstunde einzuplanen sind.
- 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
Schuljahres an die Realität anzupassen, wenn der Unterricht nicht wie geplant läuft.
### 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.
@@ -525,7 +602,8 @@ Bisher nicht vorhanden — komplett neu.
## 12. Einstellungen & Stammdaten ## 12. Einstellungen & Stammdaten
Fächer- und Kompetenzverwaltung existiert bereits in Fächer- und Kompetenzverwaltung existiert bereits in
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml). [SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml), ebenso (neu, im Zuge
von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlaufsplans.
- [ ] **12.1** Lehrerprofil: Name, Kürzel, Schule, Pflichtstundenzahl. - [ ] **12.1** Lehrerprofil: Name, Kürzel, Schule, Pflichtstundenzahl.
- [ ] **12.2** Schuljahr-Einstellungen: Beginn/Ende, Halbjahresgrenze, Ferien - [ ] **12.2** Schuljahr-Einstellungen: Beginn/Ende, Halbjahresgrenze, Ferien
@@ -769,6 +847,9 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
4. ~~**Kapitel 13** (Technische Basis: Tests, Fehlerbehandlung, Datensicherheit, Codepflege)~~ 4. ~~**Kapitel 13** (Technische Basis: Tests, Fehlerbehandlung, Datensicherheit, Codepflege)~~
erledigt (13.113.4 vollständig; 13.4.2 bewusst zurückgestellt, siehe dort). erledigt (13.113.4 vollständig; 13.4.2 bewusst zurückgestellt, siehe dort).
5. ~~**Kapitel 5** (Schülerdokumentation)~~ — erledigt (5.2 als Auswertung des bestehenden 5. ~~**Kapitel 5** (Schülerdokumentation)~~ — erledigt (5.2 als Auswertung des bestehenden
Anwesenheits-Trackings statt zweiter Erfassung, siehe dort). **Kapitel 4** (Planung) war als Anwesenheits-Trackings statt zweiter Erfassung, siehe dort).
parallelisierbar dazu vorgesehen und ist weiterhin offen. **→ nächster sinnvoller Schritt.** 6. ~~**Kapitel 4.1 + 4.2** (Unterrichtsplanung: Einheiten & Einzelstunden)~~ — erledigt.
6. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach. **Kapitel 4.3** (Stundenplan, neues Modell `TimetableSlot` + Ferien/Feiertage) und **4.4**
(Wochen-/Tageskalender über alle Gruppen) bewusst zurückgestellt — eigenständige neue
Subsysteme, kein Ausbau der bestehenden Unit/Lesson-UI. **→ nächster sinnvoller Schritt.**
7. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.
+37
View File
@@ -78,6 +78,43 @@ der häufige Kalenderzugriff auf alle Stunden einer Lerngruppe direkt indiziert
werden. Beim späteren Ausbau der Unterrichtsplanung muss sichergestellt werden, werden. Beim späteren Ausbau der Unterrichtsplanung muss sichergestellt werden,
dass `Lesson.GroupId` mit der Lerngruppe der zugehörigen Einheit übereinstimmt. dass `Lesson.GroupId` mit der Lerngruppe der zugehörigen Einheit übereinstimmt.
Umgesetzt in `PlanningTabViewModel` (Kapitel 4.1/4.2): Beim Kopieren einer Einheit
als Vorlage in eine andere Gruppe (4.1.4) wird `Lesson.GroupId` auf jeder neu
erzeugten Stunde explizit auf die Zielgruppe gesetzt, nicht von der Quell-Lesson
übernommen. Beim Verschieben einer Stunde inkl. Nachrücken der Folgestunden (4.2.4)
ändert sich ausschließlich `Lesson.Date``UnitId`/`GroupId` bleiben unangetastet.
## Stundenverlaufsplan (`Lesson.Phases`)
Eine Stunde hat statt eines einzelnen `Phase`-Textfelds plus Methoden-/Materialien-Listen eine
geordnete Liste `Lesson.Phases: List<LessonPhaseStep>` (Name, Dauer in Minuten, Tätigkeit,
Material, Kurzsymbol `Shorthand`). `DurationMinutes` ist die primäre, vom Nutzer gepflegte Größe;
die im Editor angezeigte Uhrzeit je Phase ist rein abgeleitet (kumulierte Dauer ab
`Lesson.StartTime`, sofern gesetzt) und wird nirgends persistiert — es gibt also keine
Konsistenzpflicht zwischen gespeicherter Dauer und einer gespeicherten Uhrzeit, weil Letztere gar
nicht gespeichert wird.
`Shorthand` ist bewusst ein einzelnes Freitextfeld statt einer erzwungenen Von/Nach-Struktur: in
der Praxis ist es mal ein Materialfluss-Pfeil ("AB001->S"), mal nur eine Sozialform ohne Pfeil
("Plenum", "LDE"). Der Kürzel-Katalog (`ShorthandCode`, Einstellungen) und bereits in anderen
Stunden verwendete Werte dienen nur als Autovervollständigungs-Vorschläge, erzwingen aber keine
Struktur.
`LiteDbContext`-Schema-Version 3 führt zwei aufeinanderfolgende, unabhängig versionierte
Migrationsschritte für dieses Feld:
- **v1→v2** (`MigrateLessonPhases()`): führt bereits gespeicherte alte Stunden (einzelnes
`Phase`-Feld, `Methods`/`Materials`-Listen) verlustfrei in eine einzige synthetisierte
`LessonPhaseStep`-Zeile zusammen (`Name` = altes `Phase`, `Activity` = alte `Methods` verbunden,
`Material` = alte `Materials` verbunden, `DurationMinutes = 0` da unbekannt).
- **v2→v3** (`MigrateLessonShorthand()`): das ursprünglich als Von/Nach-Paar (`ShorthandFrom`/
`ShorthandTo`) modellierte Kurzsymbol wird auf das einzelne Freitextfeld zusammengeführt (beide
gesetzt → `"Von->Nach"`, nur eines gesetzt → dieser Einzelwert).
Beide Migrationen lesen dafür die rohe `BsonDocument`-Repräsentation der `lessons`-Collection statt
der typisierten `Lessons`-Collection — nach jeder Modelländerung kennt die typisierte `Lesson`-
Klasse die alten Feldnamen nicht mehr, ein Zugriff darüber hätte sie beim Deserialisieren bereits
verworfen, bevor sie gelesen werden können.
## Eindeutige Schlüssel ## Eindeutige Schlüssel
Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes: Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes: