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:
@@ -157,3 +157,9 @@ public interface ICompetencyDomainRepository
|
||||
void Delete(Guid id);
|
||||
void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
||||
}
|
||||
public interface IShorthandCodeRepository
|
||||
{
|
||||
List<ShorthandCode> GetAll();
|
||||
void Save(ShorthandCode code);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
|
||||
@@ -51,14 +51,36 @@ public class Lesson
|
||||
public DateOnly Date { get; set; }
|
||||
public int? LessonNumber { get; set; }
|
||||
public string Topic { get; set; } = "";
|
||||
public string? Phase { get; set; }
|
||||
public List<string> Methods { get; set; } = [];
|
||||
public List<string> Materials { get; set; } = [];
|
||||
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
|
||||
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public List<LessonPhaseStep> Phases { get; set; } = [];
|
||||
public string? Homework { get; set; }
|
||||
public string? Reflection { get; set; }
|
||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <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 LessonStatus { Planned, Conducted }
|
||||
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public sealed class LiteDbContextTests
|
||||
using var temp = new TempDatabase();
|
||||
using var context = new LiteDbContext(temp.Path);
|
||||
|
||||
Assert.Equal(1, context.SchemaVersion);
|
||||
Assert.Equal(3, context.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,7 +132,96 @@ public sealed class LiteDbContextTests
|
||||
|
||||
using var second = new LiteDbContext(temp.Path);
|
||||
|
||||
Assert.Equal(1, second.SchemaVersion);
|
||||
Assert.Equal(3, second.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrateLessonPhases_FasstAltePhaseMethodenUndMaterialienVerlustfreiZusammen()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
var lessonWithDataId = Guid.NewGuid();
|
||||
var lessonEmptyId = Guid.NewGuid();
|
||||
|
||||
using (var legacy = new LiteDatabase(temp.Path))
|
||||
{
|
||||
legacy.GetCollection<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
|
||||
|
||||
@@ -424,4 +424,68 @@ public sealed class RepositoryTests
|
||||
Assert.Null(db.Documentation.FindById(doc.Id));
|
||||
Assert.Null(storage.OpenRead(attachmentId));
|
||||
}
|
||||
|
||||
// ── LessonRepository ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void LessonRepository_GetByUnit_SortiertNachDatumDannStundennummer()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new LessonRepository(db);
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
|
||||
// Doppelstunde am selben Tag: Stunde 2 vor Stunde 1 eingefügt, muss trotzdem
|
||||
// nach LessonNumber sortiert erscheinen.
|
||||
repo.Save(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2025, 9, 8), LessonNumber = 2 });
|
||||
repo.Save(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2025, 9, 8), LessonNumber = 1 });
|
||||
repo.Save(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2025, 9, 1), LessonNumber = 1 });
|
||||
|
||||
var result = repo.GetByUnit(unitId);
|
||||
|
||||
Assert.Equal(new DateOnly(2025, 9, 1), result[0].Date);
|
||||
Assert.Equal(new DateOnly(2025, 9, 8), result[1].Date);
|
||||
Assert.Equal(1, result[1].LessonNumber);
|
||||
Assert.Equal(new DateOnly(2025, 9, 8), result[2].Date);
|
||||
Assert.Equal(2, result[2].LessonNumber);
|
||||
}
|
||||
|
||||
// ── ShorthandCodeRepository ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ShorthandCodeRepository_Save_LehntDuplikatCodeAb()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new ShorthandCodeRepository(db);
|
||||
repo.Save(new ShorthandCode { Code = "Tb", Label = "Tafelbild" });
|
||||
|
||||
Assert.Throws<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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public class LiteDbContext : IDisposable
|
||||
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
|
||||
/// <see cref="RunVersionedMigrations"/> ergänzt, statt bei jedem Start erneut
|
||||
/// (idempotent, aber unnötig) über alle Daten zu laufen.
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
private const int CurrentSchemaVersion = 3;
|
||||
|
||||
private readonly LiteDatabase _db;
|
||||
|
||||
@@ -55,6 +55,7 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections");
|
||||
public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects");
|
||||
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
|
||||
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
|
||||
|
||||
public void Checkpoint() => _db.Checkpoint();
|
||||
|
||||
@@ -83,6 +84,16 @@ public class LiteDbContext : IDisposable
|
||||
MigrateExistingData();
|
||||
version = 1;
|
||||
}
|
||||
if (version < 2)
|
||||
{
|
||||
MigrateLessonPhases();
|
||||
version = 2;
|
||||
}
|
||||
if (version < 3)
|
||||
{
|
||||
MigrateLessonShorthand();
|
||||
version = 3;
|
||||
}
|
||||
WriteSchemaVersion(version);
|
||||
}
|
||||
|
||||
@@ -174,6 +185,79 @@ public class LiteDbContext : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// Fasst die frühere flache Struktur einer Stunde (einzelnes Phase-Textfeld, Methoden-/
|
||||
/// Materialien-Listen) verlustfrei in eine einzige <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()
|
||||
{
|
||||
RemoveFields("exams", "Subject");
|
||||
@@ -237,6 +321,7 @@ public class LiteDbContext : IDisposable
|
||||
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
|
||||
CompetencyDomains.EnsureIndex(x => x.SubjectId);
|
||||
CompetencyDomains.EnsureIndex(x => x.GradeLevel);
|
||||
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
@@ -211,7 +211,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
||||
public class LessonRepository(LiteDbContext db) : ILessonRepository
|
||||
{
|
||||
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) =>
|
||||
db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList();
|
||||
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 List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
|
||||
@@ -148,6 +148,56 @@ public class FakeAttachmentStorage : IAttachmentStorage
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>();
|
||||
services.AddSingleton<ISubjectRepository, SubjectRepository>();
|
||||
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
|
||||
services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>();
|
||||
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
@@ -182,6 +183,7 @@ public static class AppBootstrapper
|
||||
services.AddTransient<StudentDetailViewModel>();
|
||||
services.AddTransient<ParticipationTabViewModel>();
|
||||
services.AddTransient<GradeOverviewTabViewModel>();
|
||||
services.AddTransient<PlanningTabViewModel>();
|
||||
services.AddTransient<AddGroupDialogViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
|
||||
public ParticipationTabViewModel ParticipationTab { get; }
|
||||
public GradeOverviewTabViewModel GradeOverviewTab { get; }
|
||||
public PlanningTabViewModel PlanningTab { get; }
|
||||
public Func<Task<bool>>? OnAddStudent { get; set; }
|
||||
public Func<Guid, Task<bool>>? OnAddExam { 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,
|
||||
IGroupMembershipRepository memberships, ISubjectRepository subjects,
|
||||
IExamRepository exams, IGradeRepository grades,
|
||||
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab)
|
||||
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
|
||||
PlanningTabViewModel planningTab)
|
||||
{
|
||||
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
|
||||
_exams = exams; _grades = grades;
|
||||
ParticipationTab = participationTab;
|
||||
GradeOverviewTab = gradeOverviewTab;
|
||||
PlanningTab = planningTab;
|
||||
}
|
||||
|
||||
public void LoadGroup(Guid id)
|
||||
@@ -217,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
ReloadExams();
|
||||
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
|
||||
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
|
||||
PlanningTab.Initialize(Group.Id);
|
||||
}
|
||||
|
||||
private void ReloadExams()
|
||||
|
||||
@@ -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 IDocumentationRepository _documentation;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IShorthandCodeRepository _shorthandCodes;
|
||||
|
||||
// ── Fächer ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +36,14 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private SubjectListItem? _catalogSubject;
|
||||
@@ -110,7 +119,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||||
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
|
||||
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
|
||||
IDocumentationRepository documentation, IStudentRepository students)
|
||||
IDocumentationRepository documentation, IStudentRepository students,
|
||||
IShorthandCodeRepository shorthandCodes)
|
||||
{
|
||||
_subjects = subjects;
|
||||
_domainRepo = domainRepo;
|
||||
@@ -124,7 +134,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_privacy = privacy;
|
||||
_documentation = documentation;
|
||||
_students = students;
|
||||
_shorthandCodes = shorthandCodes;
|
||||
LoadSubjects();
|
||||
LoadShorthandCodes();
|
||||
LoadGradingKeyTemplates();
|
||||
LoadGradingSchemes();
|
||||
LoadBackups();
|
||||
@@ -381,6 +393,40 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadSubjects();
|
||||
}
|
||||
|
||||
// ── Kürzel-Katalog: Laden / Hinzufügen / Löschen ─────────────────────────
|
||||
|
||||
public void LoadShorthandCodes()
|
||||
{
|
||||
ShorthandCodes.Clear();
|
||||
foreach (var c in _shorthandCodes.GetAll())
|
||||
ShorthandCodes.Add(new ShorthandCodeListItem(c));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddShorthandCode()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewShorthandCode)) { NewShorthandCodeError = "Kürzel erforderlich."; return; }
|
||||
try
|
||||
{
|
||||
_shorthandCodes.Save(new ShorthandCode { Code = NewShorthandCode.Trim(), Label = NewShorthandLabel.Trim() });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
NewShorthandCodeError = ex.Message;
|
||||
return;
|
||||
}
|
||||
NewShorthandCode = ""; NewShorthandLabel = ""; NewShorthandCodeError = "";
|
||||
LoadShorthandCodes();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void DeleteShorthandCode(ShorthandCodeListItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_shorthandCodes.Delete(item.Id);
|
||||
LoadShorthandCodes();
|
||||
}
|
||||
|
||||
// ── Katalog: Laden ────────────────────────────────────────────────────────
|
||||
|
||||
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
|
||||
@@ -703,6 +749,13 @@ public class SubjectListItem(Subject s)
|
||||
public string ShortName { get; } = s.ShortName;
|
||||
}
|
||||
|
||||
public class ShorthandCodeListItem(ShorthandCode c)
|
||||
{
|
||||
public Guid Id { get; } = c.Id;
|
||||
public string Code { get; } = c.Code;
|
||||
public string Label { get; } = c.Label;
|
||||
}
|
||||
|
||||
public class BackupListItem(BackupInfo info)
|
||||
{
|
||||
public string Path { get; } = info.Path;
|
||||
|
||||
@@ -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 -->
|
||||
<ContentPage Header="Planung">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="Unterrichtseinheiten" FontSize="16" Opacity="0.4"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<views:PlanningTabView DataContext="{Binding PlanningTab}"/>
|
||||
</ContentPage>
|
||||
|
||||
<!-- 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->S, Plenum, LDE"
|
||||
ToolTip.Tip="Freitext für den schnellen Überblick — mal ein Materialfluss-Pfeil (AB001->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>
|
||||
</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 "Tb->SH" 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 -->
|
||||
<ContentPage Header="Kompetenzen">
|
||||
<ScrollViewer>
|
||||
|
||||
@@ -224,25 +224,102 @@ zusammenziehen, daraus die Halbjahresnote bilden":
|
||||
## 4. Unterrichtsplanung
|
||||
|
||||
Modelle `Unit` und `Lesson` existieren, `UnitRepository`/`LessonRepository` ebenfalls.
|
||||
Navigationspunkt "Unterrichtsplanung" ist ein `PlaceholderViewModel`
|
||||
([MainWindowViewModel.cs:41](LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs#L41)).
|
||||
Navigationspunkt "Unterrichtsplanung" ist weiterhin ein `PlaceholderViewModel`
|
||||
([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.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").
|
||||
- [ ] **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.
|
||||
- [ ] **4.1.4** Einheit als Vorlage speichern und in eine andere Gruppe kopieren
|
||||
- [x] **4.1.2** Dialog Einheit anlegen/bearbeiten: Titel, Fach, Zeitraum, Status, Notizen.
|
||||
- [x] **4.1.3** Kompetenzen aus dem Katalog (siehe 8) einer Einheit zuordnen — Mehrfachauswahl.
|
||||
- [x] **4.1.4** Einheit als Vorlage speichern und in eine andere Gruppe kopieren
|
||||
(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.1** Stundenliste innerhalb einer Einheit, sortiert nach Datum/Stundennummer.
|
||||
- [ ] **4.2.2** Stundeneditor: Thema, Phase, Methoden, Materialien, Hausaufgabe.
|
||||
Methoden/Materialien als Chips mit Autovervollständigung aus bisherigen Einträgen.
|
||||
- [ ] **4.2.3** Status `Planned → Conducted` setzen, Reflexionsfeld nach der Stunde.
|
||||
- [ ] **4.2.4** Stunden verschieben (z.B. bei Ausfall) — Folgestunden automatisch nachrücken.
|
||||
- [ ] **4.2.5** Stunden serienweise aus dem Stundenplan (4.3) erzeugen.
|
||||
- [x] **4.2.1** Stundenliste innerhalb einer Einheit, sortiert nach Datum/Stundennummer.
|
||||
- [x] **4.2.2** Stundeneditor als tabellarischer Verlaufsplan (Vorbild: vom Nutzer bereitgestelltes
|
||||
Beispiel eines realen Stundenverlaufsplans) statt einzelnem Phase-Textfeld + Methoden-/
|
||||
Materialien-Chips: mehrere Phasen-Zeilen (Einstieg, Erarbeitung 1, Sicherung 1, ...), je
|
||||
Zeile Name, Dauer (Minuten), Tätigkeit, Material (mit Autovervollständigung aus bisherigen
|
||||
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.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository.
|
||||
@@ -525,7 +602,8 @@ Bisher nicht vorhanden — komplett neu.
|
||||
## 12. Einstellungen & Stammdaten
|
||||
|
||||
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.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)~~ —
|
||||
erledigt (13.1–13.4 vollständig; 13.4.2 bewusst zurückgestellt, siehe dort).
|
||||
5. ~~**Kapitel 5** (Schülerdokumentation)~~ — erledigt (5.2 als Auswertung des bestehenden
|
||||
Anwesenheits-Trackings statt zweiter Erfassung, siehe dort). **Kapitel 4** (Planung) war als
|
||||
parallelisierbar dazu vorgesehen und ist weiterhin offen. **→ nächster sinnvoller Schritt.**
|
||||
6. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.
|
||||
Anwesenheits-Trackings statt zweiter Erfassung, siehe dort).
|
||||
6. ~~**Kapitel 4.1 + 4.2** (Unterrichtsplanung: Einheiten & Einzelstunden)~~ — erledigt.
|
||||
**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.
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
Die Datenbank schützt folgende Kombinationen mit eindeutigen Indizes:
|
||||
|
||||
Reference in New Issue
Block a user