From 8efeb68e936348f54ae19e49b9dc3849a128cd28 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 02:04:29 +0200 Subject: [PATCH] feat: add planning JSON exchange --- .../PlanningExchangeServiceTests.cs | 130 ++++++++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../Einheitenplanung-Format.md | 107 +++++++ .../PlanningFormats/Stundenplanung-Format.md | 90 ++++++ .../Services/PlanningExchangeService.cs | 278 ++++++++++++++++++ .../ViewModels/Groups/PlanningViewModels.cs | 24 ++ .../Views/Groups/PlanningTabView.axaml | 24 +- .../Views/Groups/PlanningTabView.axaml.cs | 145 +++++++++ 8 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs create mode 100644 LehrerApp.Desktop/Assets/PlanningFormats/Einheitenplanung-Format.md create mode 100644 LehrerApp.Desktop/Assets/PlanningFormats/Stundenplanung-Format.md create mode 100644 LehrerApp.Desktop/Services/PlanningExchangeService.cs diff --git a/LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs b/LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs new file mode 100644 index 0000000..542ca31 --- /dev/null +++ b/LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs @@ -0,0 +1,130 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using System.Text.Json; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class PlanningExchangeServiceTests +{ + [Fact] + public void UnitRoundtrip_ErzeugtNeueObjekteUndErhaeltPlanungsdaten() + { + var units = new FakeUnits(); + var lessons = new FakeLessons(); + var shortPath = new AlternativeLessonPath { Name = "Kurzversion" }; + var paths = new FakeAlternativeLessonPaths([shortPath]); + var service = new PlanningExchangeService(units, lessons, paths); + var sourceGroupId = Guid.NewGuid(); + var targetGroupId = Guid.NewGuid(); + var unit = new Unit + { + GroupId = sourceGroupId, + Title = "Optik", + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2026, 10, 1), + Competencies = ["UF1", "E4"], + Status = UnitStatus.Active, + Notes = "Experimente", + }; + units.Add(unit); + var sourceLesson = new Lesson + { + UnitId = unit.Id, + GroupId = sourceGroupId, + Date = new DateOnly(2026, 9, 8), + LessonNumber = 3, + Topic = "Reflexion", + StartTime = new TimeOnly(9, 50), + Status = LessonStatus.Planned, + Homework = "Aufgabe 2", + Phases = + [ + new LessonPhaseStep + { + Name = "Experiment", DurationMinutes = 25, Activity = "Winkel messen", + Material = "Optikbox", Shorthand = "PA", AlternativePathId = shortPath.Id, + }, + ], + }; + lessons.Add(sourceLesson); + + var json = service.ExportUnit(unit, new PlanningExchangeContext + { + Group = "8a", Subject = "Physik", GradeLevel = 8, + }); + var result = service.ImportUnit(json, targetGroupId); + + Assert.DoesNotContain(unit.Id.ToString(), json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(sourceLesson.Id.ToString(), json, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(unit.Id, result.Unit.Id); + Assert.Equal(targetGroupId, result.Unit.GroupId); + Assert.Equal("Optik", result.Unit.Title); + Assert.Equal(UnitStatus.Active, result.Unit.Status); + Assert.Equal(["UF1", "E4"], result.Unit.Competencies); + var importedLesson = Assert.Single(result.Lessons); + Assert.NotEqual(sourceLesson.Id, importedLesson.Id); + Assert.Equal(result.Unit.Id, importedLesson.UnitId); + Assert.Equal(targetGroupId, importedLesson.GroupId); + Assert.Equal(new TimeOnly(9, 50), importedLesson.StartTime); + Assert.Equal(shortPath.Id, Assert.Single(importedLesson.Phases).AlternativePathId); + } + + [Fact] + public void LessonImport_AkzeptiertMarkdownCodeblockUndLegtNeueStundeAn() + { + var units = new FakeUnits(); + var lessons = new FakeLessons(); + var service = new PlanningExchangeService(units, lessons, new FakeAlternativeLessonPaths([])); + var source = new Lesson + { + UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new DateOnly(2026, 9, 8), + Topic = "Reflexion", Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 }], + }; + lessons.Add(source); + var json = service.ExportLesson(source, new PlanningExchangeContext()); + var targetUnitId = Guid.NewGuid(); + var targetGroupId = Guid.NewGuid(); + + var imported = service.ImportLesson($"```json\n{json}\n```", targetUnitId, targetGroupId); + + Assert.NotEqual(source.Id, imported.Id); + Assert.Equal(targetUnitId, imported.UnitId); + Assert.Equal(targetGroupId, imported.GroupId); + Assert.Equal("Reflexion", imported.Topic); + Assert.Equal(10, Assert.Single(imported.Phases).DurationMinutes); + } + + [Fact] + public void ImportMitFalschemSchema_SchreibtKeineDaten() + { + var units = new FakeUnits(); + var lessons = new FakeLessons(); + var service = new PlanningExchangeService(units, lessons, new FakeAlternativeLessonPaths([])); + const string json = """ + { "schema": "anderes.format", "version": 1, "unit": { "title": "Optik" } } + """; + var groupId = Guid.NewGuid(); + + var error = Assert.Throws(() => service.ImportUnit(json, groupId)); + + Assert.Contains("Falsches Format", error.Message); + Assert.Empty(units.GetByGroup(groupId)); + Assert.Empty(lessons.GetByGroupAndRange(groupId, DateOnly.MinValue, DateOnly.MaxValue)); + } + + [Fact] + public void ExportVerwendetDokumentiertesSchemaUndCamelCase() + { + var service = new PlanningExchangeService(new FakeUnits(), new FakeLessons(), + new FakeAlternativeLessonPaths([])); + var lesson = new Lesson { Date = new DateOnly(2026, 9, 8), Topic = "Optik" }; + + using var json = JsonDocument.Parse(service.ExportLesson(lesson, new PlanningExchangeContext())); + + Assert.Equal(PlanningExchangeService.LessonSchema, json.RootElement.GetProperty("schema").GetString()); + Assert.Equal(1, json.RootElement.GetProperty("version").GetInt32()); + Assert.Equal("2026-09-08", json.RootElement.GetProperty("lesson").GetProperty("date").GetString()); + Assert.Equal("planned", json.RootElement.GetProperty("lesson").GetProperty("status").GetString()); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 24af746..94a4e77 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -156,6 +156,7 @@ public static class AppBootstrapper services.AddSingleton(_ => new WorkloadSettingsService(appData)); services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new LetterTemplateService(appData)); + services.AddSingleton(); // ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ────── services.AddSingleton(_ => new AiSettingsService(appData)); diff --git a/LehrerApp.Desktop/Assets/PlanningFormats/Einheitenplanung-Format.md b/LehrerApp.Desktop/Assets/PlanningFormats/Einheitenplanung-Format.md new file mode 100644 index 0000000..74a0abc --- /dev/null +++ b/LehrerApp.Desktop/Assets/PlanningFormats/Einheitenplanung-Format.md @@ -0,0 +1,107 @@ +# LehrerApp-Austauschformat: Unterrichtseinheit + +Dieses Dokument beschreibt Version 1 des JSON-Formats `lehrerapp.unit-planning`. +Es dient zum Austausch einer vollständigen Unterrichtseinheit einschließlich ihrer Stunden. + +## Grundregeln + +- Die Datei enthält reines UTF-8-JSON. Beim Import wird auch ein einzelner Markdown-Codeblock akzeptiert. +- Datumswerte verwenden `JJJJ-MM-TT`, Uhrzeiten `HH:MM` oder `HH:MM:SS`. +- Statuswerte sind `planned`, `active`, `completed` für Einheiten und `planned`, `conducted` für Stunden. +- Interne IDs werden nicht übertragen. Der Import legt eine neue Einheit und neue Stunden in der aktuell geöffneten Lerngruppe an. +- `context` hilft bei der Bearbeitung mit einer KI, wird beim Import aber nicht zur Zuordnung verwendet. +- Unbekannte zusätzliche Felder werden ignoriert. + +## Beispiel + +```json +{ + "schema": "lehrerapp.unit-planning", + "version": 1, + "context": { + "group": "8a", + "subject": "Physik", + "gradeLevel": 8, + "unitTitle": null + }, + "unit": { + "title": "Optik: Reflexion und Brechung", + "startDate": "2026-09-01", + "endDate": "2026-10-06", + "competencies": ["UF1", "E4"], + "status": "planned", + "notes": "Experimente in Kleingruppen", + "lessons": [ + { + "date": "2026-09-01", + "lessonNumber": 3, + "topic": "Lichtausbreitung", + "startTime": "09:50:00", + "status": "planned", + "homework": "Aufgabe 2 beenden", + "reflection": null, + "phases": [ + { + "name": "Einstieg", + "durationMinutes": 10, + "activity": "Impulsbild beschreiben und Vermutungen sammeln", + "material": "Beamer", + "shorthand": "Plenum", + "alternativePath": null + }, + { + "name": "Erarbeitung", + "durationMinutes": 25, + "activity": "Versuch zur geradlinigen Lichtausbreitung", + "material": "Experimentierbox", + "shorthand": "GA", + "alternativePath": "Kurzversion" + } + ] + } + ] + } +} +``` + +## Felder + +### Dokument + +- `schema` (Pflicht): exakt `lehrerapp.unit-planning` +- `version` (Pflicht): aktuell `1` +- `context` (optional): `group`, `subject`, `gradeLevel` und optional `unitTitle` +- `unit` (Pflicht): die zu importierende Einheit + +### Einheit + +- `title` (Pflicht, Text) +- `startDate`, `endDate` (optional, Datum oder `null`); das Ende darf nicht vor dem Start liegen +- `competencies` (Liste von Kompetenzcodes oder Freitexten) +- `status` (optional, Standard `planned`) +- `notes` (optional, Text oder `null`) +- `lessons` (Liste, darf leer sein); Aufbau siehe Stundenformat unten + +### Stunde + +- `date` (Pflicht, Datum) +- `lessonNumber` (optional, Ganzzahl 1–20 oder `null`) +- `topic` (Pflicht, Text) +- `startTime` (optional, Uhrzeit oder `null`) +- `status` (optional: `planned` oder `conducted`, Standard `planned`) +- `homework`, `reflection` (optional, Text oder `null`) +- `phases` (Liste, darf leer sein) + +### Phase + +- `name`, `activity`, `material`, `shorthand` (Text; leere Werte sind erlaubt) +- `durationMinutes` (Ganzzahl 0–180) +- `alternativePath` (optional): lesbarer Name eines alternativen Verlaufs, z. B. `Kurzversion`. Beim Import wird ein vorhandener gleichnamiger Ablauf verwendet oder neu angelegt. + +## Hinweise für KI-Bearbeitung + +- JSON-Struktur, `schema` und `version` unverändert lassen. +- Nur gültige ISO-Daten verwenden. +- Phasendauern so wählen, dass ihre Summe zur verfügbaren Unterrichtszeit passt. +- Keine IDs ergänzen; Beziehungen werden beim Import automatisch hergestellt. + diff --git a/LehrerApp.Desktop/Assets/PlanningFormats/Stundenplanung-Format.md b/LehrerApp.Desktop/Assets/PlanningFormats/Stundenplanung-Format.md new file mode 100644 index 0000000..b5c05d0 --- /dev/null +++ b/LehrerApp.Desktop/Assets/PlanningFormats/Stundenplanung-Format.md @@ -0,0 +1,90 @@ +# LehrerApp-Austauschformat: einzelne Stundenplanung + +Dieses Dokument beschreibt Version 1 des JSON-Formats `lehrerapp.lesson-planning`. +Es dient zum Austausch genau einer Stunde mit ihrem Verlaufsplan. + +## Grundregeln + +- Die Datei enthält reines UTF-8-JSON. Beim Import wird auch ein einzelner Markdown-Codeblock akzeptiert. +- Datum: `JJJJ-MM-TT`; Uhrzeit: `HH:MM` oder `HH:MM:SS`. +- Interne IDs werden nicht übertragen. Der Import legt eine neue Stunde in der aktuell ausgewählten Einheit an. +- `context` ist nur Bearbeitungshilfe und entscheidet nicht über das Importziel. +- Unbekannte zusätzliche Felder werden ignoriert. + +## Beispiel + +```json +{ + "schema": "lehrerapp.lesson-planning", + "version": 1, + "context": { + "group": "8a", + "subject": "Physik", + "gradeLevel": 8, + "unitTitle": "Optik" + }, + "lesson": { + "date": "2026-09-08", + "lessonNumber": 3, + "topic": "Reflexionsgesetz", + "startTime": "09:50:00", + "status": "planned", + "homework": "Versuchsprotokoll fertigstellen", + "reflection": null, + "phases": [ + { + "name": "Einstieg", + "durationMinutes": 8, + "activity": "Alltagsbeispiele für Reflexion sammeln", + "material": "Spiegel", + "shorthand": "UG", + "alternativePath": null + }, + { + "name": "Experiment", + "durationMinutes": 27, + "activity": "Einfalls- und Ausfallswinkel messen", + "material": "Optikbox, AB 03", + "shorthand": "PA", + "alternativePath": null + } + ] + } +} +``` + +## Felder + +### Dokument + +- `schema` (Pflicht): exakt `lehrerapp.lesson-planning` +- `version` (Pflicht): aktuell `1` +- `context` (optional): `group`, `subject`, `gradeLevel`, `unitTitle` +- `lesson` (Pflicht): die neue Stundenplanung + +### Stunde + +- `date` (Pflicht, Datum) +- `lessonNumber` (optional, Ganzzahl 1–20 oder `null`) +- `topic` (Pflicht, Text) +- `startTime` (optional, Uhrzeit oder `null`) +- `status` (optional: `planned` oder `conducted`, Standard `planned`) +- `homework`, `reflection` (optional, Text oder `null`) +- `phases` (Liste, darf leer sein) + +### Phase + +- `name`: Bezeichnung, z. B. `Einstieg` oder `Sicherung` +- `durationMinutes`: Ganzzahl 0–180 +- `activity`: Lehrer-/Schüler-Tätigkeit +- `material`: benötigte Medien und Materialien +- `shorthand`: Kurzsymbol oder Sozialform +- `alternativePath` (optional): Name eines alternativen Verlaufs. Ein vorhandener gleichnamiger Ablauf wird wiederverwendet, andernfalls neu angelegt. + +## Hinweise für KI-Bearbeitung + +- JSON-Struktur, `schema` und `version` unverändert lassen. +- Keine IDs ergänzen. +- Das Datum muss gesetzt sein; die Stundennummer darf fehlen. +- Die Summe der `durationMinutes` sollte zur verfügbaren Unterrichtszeit passen. + diff --git a/LehrerApp.Desktop/Services/PlanningExchangeService.cs b/LehrerApp.Desktop/Services/PlanningExchangeService.cs new file mode 100644 index 0000000..05db7c3 --- /dev/null +++ b/LehrerApp.Desktop/Services/PlanningExchangeService.cs @@ -0,0 +1,278 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LehrerApp.Desktop.Services; + +/// +/// Portables, menschen- und KI-lesbares Austauschformat für Unterrichtseinheiten und +/// Einzelstunden. Interne Datenbank-IDs werden nie übertragen; jeder Import legt neue Objekte an. +/// +public sealed class PlanningExchangeService(IUnitRepository units, ILessonRepository lessons, + IAlternativeLessonPathRepository alternativePaths) +{ + public const string UnitSchema = "lehrerapp.unit-planning"; + public const string LessonSchema = "lehrerapp.lesson-planning"; + public const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + public string ExportUnit(Unit unit, PlanningExchangeContext context) + { + var document = new UnitPlanningDocument + { + Schema = UnitSchema, + Version = CurrentVersion, + Context = context, + Unit = ToPayload(unit, lessons.GetByUnit(unit.Id)), + }; + return JsonSerializer.Serialize(document, JsonOptions); + } + + public string ExportLesson(Lesson lesson, PlanningExchangeContext context) + { + var document = new LessonPlanningDocument + { + Schema = LessonSchema, + Version = CurrentVersion, + Context = context, + Lesson = ToPayload(lesson), + }; + return JsonSerializer.Serialize(document, JsonOptions); + } + + public UnitImportResult ImportUnit(string json, Guid targetGroupId) + { + var document = Deserialize(json, UnitSchema); + ValidateUnit(document.Unit); + foreach (var lesson in document.Unit.Lessons) ValidateLesson(lesson); + + var unit = new Unit + { + GroupId = targetGroupId, + Title = document.Unit.Title.Trim(), + StartDate = document.Unit.StartDate, + EndDate = document.Unit.EndDate, + Competencies = document.Unit.Competencies + .Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + Status = document.Unit.Status, + Notes = Clean(document.Unit.Notes), + }; + units.Save(unit); + + var importedLessons = new List(); + foreach (var payload in document.Unit.Lessons) + { + var lesson = ToModel(payload, unit.Id, targetGroupId); + lessons.Save(lesson); + importedLessons.Add(lesson); + } + return new UnitImportResult(unit, importedLessons); + } + + public Lesson ImportLesson(string json, Guid targetUnitId, Guid targetGroupId) + { + var document = Deserialize(json, LessonSchema); + ValidateLesson(document.Lesson); + var lesson = ToModel(document.Lesson, targetUnitId, targetGroupId); + lessons.Save(lesson); + return lesson; + } + + private UnitPlanningPayload ToPayload(Unit unit, List unitLessons) => new() + { + Title = unit.Title, + StartDate = unit.StartDate, + EndDate = unit.EndDate, + Competencies = [.. unit.Competencies], + Status = unit.Status, + Notes = unit.Notes, + Lessons = [.. unitLessons.Select(ToPayload)], + }; + + private LessonPlanningPayload ToPayload(Lesson lesson) => new() + { + Date = lesson.Date, + LessonNumber = lesson.LessonNumber, + Topic = lesson.Topic, + StartTime = lesson.StartTime, + Status = lesson.Status, + Homework = lesson.Homework, + Reflection = lesson.Reflection, + Phases = [.. lesson.Phases.Select(p => new LessonPhasePayload + { + Name = p.Name, + DurationMinutes = p.DurationMinutes, + Activity = p.Activity, + Material = p.Material, + Shorthand = p.Shorthand, + AlternativePath = p.AlternativePathId is Guid id ? alternativePaths.GetById(id)?.Name : null, + })], + }; + + private Lesson ToModel(LessonPlanningPayload payload, Guid unitId, Guid groupId) => new() + { + UnitId = unitId, + GroupId = groupId, + Date = payload.Date!.Value, + LessonNumber = payload.LessonNumber, + Topic = payload.Topic.Trim(), + StartTime = payload.StartTime, + Status = payload.Status, + Homework = Clean(payload.Homework), + Reflection = Clean(payload.Reflection), + Phases = [.. payload.Phases.Select(p => new LessonPhaseStep + { + Name = p.Name?.Trim() ?? "", + DurationMinutes = p.DurationMinutes, + Activity = p.Activity?.Trim() ?? "", + Material = p.Material?.Trim() ?? "", + Shorthand = p.Shorthand?.Trim() ?? "", + AlternativePathId = ResolveAlternativePath(p.AlternativePath), + })], + }; + + private Guid? ResolveAlternativePath(string? name) + { + name = Clean(name); + if (name is null) return null; + var existing = alternativePaths.GetAll().FirstOrDefault(p => + string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) return existing.Id; + var created = new AlternativeLessonPath { Name = name }; + alternativePaths.Save(created); + return created.Id; + } + + private static T Deserialize(string json, string expectedSchema) where T : PlanningDocument + { + try + { + var document = JsonSerializer.Deserialize(RemoveMarkdownFence(json), JsonOptions) + ?? throw new PlanningExchangeException("Die JSON-Datei ist leer."); + if (!string.Equals(document.Schema, expectedSchema, StringComparison.Ordinal)) + throw new PlanningExchangeException($"Falsches Format: Erwartet wird „{expectedSchema}“."); + if (document.Version != CurrentVersion) + throw new PlanningExchangeException($"Die Formatversion {document.Version} wird nicht unterstützt."); + return document; + } + catch (JsonException ex) + { + throw new PlanningExchangeException($"Die JSON-Datei ist ungültig: {ex.Message}", ex); + } + } + + private static string RemoveMarkdownFence(string json) + { + var value = json.Trim(); + if (!value.StartsWith("```", StringComparison.Ordinal)) return value; + var firstLineEnd = value.IndexOf('\n'); + if (firstLineEnd < 0) return value; + value = value[(firstLineEnd + 1)..]; + var closingFence = value.LastIndexOf("```", StringComparison.Ordinal); + return closingFence >= 0 ? value[..closingFence].Trim() : value.Trim(); + } + + private static void ValidateUnit(UnitPlanningPayload? unit) + { + if (unit is null) throw new PlanningExchangeException("Das Feld „unit“ fehlt."); + if (string.IsNullOrWhiteSpace(unit.Title)) + throw new PlanningExchangeException("Die Einheit benötigt einen Titel."); + if (unit.StartDate is not null && unit.EndDate is not null && unit.EndDate < unit.StartDate) + throw new PlanningExchangeException("Das Enddatum der Einheit liegt vor dem Startdatum."); + unit.Competencies ??= []; + unit.Lessons ??= []; + } + + private static void ValidateLesson(LessonPlanningPayload? lesson) + { + if (lesson is null) throw new PlanningExchangeException("Das Feld „lesson“ fehlt."); + if (lesson.Date is null) throw new PlanningExchangeException("Die Stunde benötigt ein Datum im Format JJJJ-MM-TT."); + if (string.IsNullOrWhiteSpace(lesson.Topic)) + throw new PlanningExchangeException("Die Stunde benötigt ein Thema."); + if (lesson.LessonNumber is < 1 or > 20) + throw new PlanningExchangeException("Die Stundennummer muss zwischen 1 und 20 liegen."); + lesson.Phases ??= []; + if (lesson.Phases.Any(p => p.DurationMinutes is < 0 or > 180)) + throw new PlanningExchangeException("Die Dauer jeder Phase muss zwischen 0 und 180 Minuten liegen."); + } + + private static string? Clean(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +public abstract class PlanningDocument +{ + public string Schema { get; set; } = ""; + public int Version { get; set; } + public PlanningExchangeContext? Context { get; set; } +} + +public sealed class UnitPlanningDocument : PlanningDocument +{ + public UnitPlanningPayload Unit { get; set; } = new(); +} + +public sealed class LessonPlanningDocument : PlanningDocument +{ + public LessonPlanningPayload Lesson { get; set; } = new(); +} + +public sealed class PlanningExchangeContext +{ + public string Group { get; set; } = ""; + public string Subject { get; set; } = ""; + public int GradeLevel { get; set; } + public string? UnitTitle { get; set; } +} + +public sealed class UnitPlanningPayload +{ + public string Title { get; set; } = ""; + public DateOnly? StartDate { get; set; } + public DateOnly? EndDate { get; set; } + public List Competencies { get; set; } = []; + public UnitStatus Status { get; set; } = UnitStatus.Planned; + public string? Notes { get; set; } + public List Lessons { get; set; } = []; +} + +public sealed class LessonPlanningPayload +{ + public DateOnly? Date { get; set; } + public int? LessonNumber { get; set; } + public string Topic { get; set; } = ""; + public TimeOnly? StartTime { get; set; } + public LessonStatus Status { get; set; } = LessonStatus.Planned; + public string? Homework { get; set; } + public string? Reflection { get; set; } + public List Phases { get; set; } = []; +} + +public sealed class LessonPhasePayload +{ + 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 string? AlternativePath { get; set; } +} + +public sealed record UnitImportResult(Unit Unit, List Lessons); + +public sealed class PlanningExchangeException : Exception +{ + public PlanningExchangeException(string message) : base(message) { } + public PlanningExchangeException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 7f44082..2922c0b 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -49,6 +49,10 @@ public partial class PlanningTabViewModel : ObservableObject // 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 bool HasUnitSelection => SelectedUnit is not null; + public bool HasLessonSelection => SelectedLesson is not null; + public bool CanImportUnitPlanning => !IsReadOnly; + public bool CanImportLessonPlanning => !IsReadOnly && SelectedUnit is not null; public ObservableCollection Units { get; } = []; public ObservableCollection Lessons { get; } = []; @@ -123,6 +127,8 @@ public partial class PlanningTabViewModel : ObservableObject { LoadLessons(); OnPropertyChanged(nameof(SelectedUnitTitleSuffix)); + OnPropertyChanged(nameof(HasUnitSelection)); + OnPropertyChanged(nameof(CanImportLessonPlanning)); EditUnitCommand.NotifyCanExecuteChanged(); DeleteUnitCommand.NotifyCanExecuteChanged(); CopyUnitCommand.NotifyCanExecuteChanged(); @@ -143,6 +149,7 @@ public partial class PlanningTabViewModel : ObservableObject partial void OnSelectedLessonChanged(LessonSummary? value) { + OnPropertyChanged(nameof(HasLessonSelection)); ShowLessonCommand.NotifyCanExecuteChanged(); EditLessonCommand.NotifyCanExecuteChanged(); DeleteLessonCommand.NotifyCanExecuteChanged(); @@ -150,6 +157,23 @@ public partial class PlanningTabViewModel : ObservableObject AdvanceLessonStatusCommand.NotifyCanExecuteChanged(); } + partial void OnIsReadOnlyChanged(bool value) + { + OnPropertyChanged(nameof(CanImportUnitPlanning)); + OnPropertyChanged(nameof(CanImportLessonPlanning)); + } + + public void RefreshPlanning(Guid? selectUnitId = null, Guid? selectLessonId = null) + { + selectUnitId ??= SelectedUnit?.Id; + selectLessonId ??= SelectedLesson?.Id; + LoadUnits(); + if (selectUnitId is Guid unitId) + SelectedUnit = Units.FirstOrDefault(u => u.Id == unitId) ?? SelectedUnit; + if (selectLessonId is Guid lessonId) + SelectedLesson = Lessons.FirstOrDefault(l => l.Id == lessonId); + } + private bool HasSelectedUnit() => SelectedUnit is not null; private bool HasSelectedLesson() => SelectedLesson is not null; private bool CanAiAssist() => SelectedUnit is not null && _aiSettings.Enabled; diff --git a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml index ad827ff..a4a2592 100644 --- a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml @@ -8,8 +8,28 @@ - + + + +