diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs
index a9dca31..fade0f8 100644
--- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs
+++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs
@@ -119,3 +119,37 @@ public class AiExplainResponse
{
public string Explanation { get; set; } = "";
}
+
+///
+/// Wire-Vertrag für den Gefährdungsbeurteilungs-Entwurf (ai-backend/gbu.php, Nutzerwunsch neben
+/// 4.2 "Anhänge je Stunde"): erkennt aus Thema/Verlaufsplan der Lesson das Experiment und liefert
+/// dafür einen strukturierten Entwurf. Eigener Endpunkt statt Zusatzfeld in plan.php/explain.php,
+/// da inhaltlich unabhängig von der Unterrichtsplanung selbst.
+///
+public class AiHazardAssessmentRequest
+{
+ public AiUnitContext Unit { get; set; } = new();
+ public AiLesson Lesson { get; set; } = new();
+}
+
+public class AiHazardAssessmentResponse
+{
+ /// "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch" (siehe ExperimentKind).
+ public string ExperimentKind { get; set; } = "";
+ public string Procedure { get; set; } = "";
+ public List Substances { get; set; } = [];
+ public List Hazards { get; set; } = [];
+ public List ProtectiveMeasures { get; set; } = [];
+ public string FirstAid { get; set; } = "";
+ public string Disposal { get; set; } = "";
+}
+
+public class AiHazardSubstance
+{
+ public string Name { get; set; } = "";
+ public string Amount { get; set; } = "";
+ /// GHS-Piktogramm-Codes als Text, z.B. "GHS05" — siehe GhsPictogram für die Zuordnung.
+ public List GhsPictograms { get; set; } = [];
+ public string HStatements { get; set; } = "";
+ public string PStatements { get; set; } = "";
+}
diff --git a/LehrerApp.Core/Models/HazardAssessment.cs b/LehrerApp.Core/Models/HazardAssessment.cs
new file mode 100644
index 0000000..985d67a
--- /dev/null
+++ b/LehrerApp.Core/Models/HazardAssessment.cs
@@ -0,0 +1,52 @@
+namespace LehrerApp.Core.Models;
+
+///
+/// Gefährdungsbeurteilung zu einem Experiment (fachspezifisch, v.a. Chemie/Naturwissenschaften).
+/// Lebt NICHT als eigene LiteDB-Collection/Repository, sondern wird als JSON serialisiert und
+/// über die bestehende -Infrastruktur an eine
+/// angehängt (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2) — das reicht für Bearbeiten,
+/// Anzeigen und PDF-Export, ohne eine weitere synchronisierte Entität samt Repository/DI-Wiring
+/// einzuführen (bewusste Scope-Entscheidung, siehe TODO.md).
+///
+public class HazardAssessment
+{
+ public string Title { get; set; } = "";
+ /// Klassenstufe/Kurs, z.B. "10c" — vorbelegt aus der zugehörigen Lerngruppe, frei änderbar.
+ public string GroupLabel { get; set; } = "";
+ public DateOnly? Date { get; set; }
+ public ExperimentKind Kind { get; set; } = ExperimentKind.Lehrerversuch;
+ /// Kurzbeschreibung der Durchführung.
+ public string Procedure { get; set; } = "";
+ public List Substances { get; set; } = [];
+ public List Hazards { get; set; } = [];
+ public List ProtectiveMeasures { get; set; } = [];
+ public string FirstAid { get; set; } = "";
+ public string Disposal { get; set; } = "";
+ public string Notes { get; set; } = "";
+ /// Ob ein KI-Entwurf beteiligt war — steuert den Rechtssicherheits-Hinweis im PDF-Export.
+ public bool IsAiAssisted { get; set; }
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
+
+public class HazardSubstance
+{
+ public string Name { get; set; } = "";
+ /// Typische Einsatzmenge, z.B. "ca. 2 g" — Freitext statt strukturierter Einheit, da der
+ /// Rahmen (fest/Lösung/Gas, Konzentration ...) zu unterschiedlich für ein starres Feld ist.
+ public string Amount { get; set; } = "";
+ public List GhsPictograms { get; set; } = [];
+ /// H-Sätze (Gefahrenhinweise) als Freitext, z.B. "H314, H290" oder mit Kurztext — bewusst kein
+ /// eingebauter Katalog amtlicher H-/P-Satz-Texte in der App (Fehlerrisiko bei
+ /// sicherheitsrelevanten Angaben), die Lehrkraft prüft/ergänzt gegen das Sicherheitsdatenblatt.
+ public string HStatements { get; set; } = "";
+ public string PStatements { get; set; } = "";
+}
+
+public enum ExperimentKind { Lehrerversuch, Schuelerversuch, Demonstrationsversuch }
+
+/// Die neun GHS-Gefahrenpiktogramme (Global Harmonisiertes System).
+public enum GhsPictogram
+{
+ Explosive, Flammable, Oxidizing, CompressedGas, Corrosive,
+ Toxic, Harmful, HealthHazard, Environmental,
+}
diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs
index eef5d0d..6fcb6cc 100644
--- a/LehrerApp.Core/Models/Planning.cs
+++ b/LehrerApp.Core/Models/Planning.cs
@@ -43,7 +43,7 @@ public class Unit
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
-public class Lesson
+public class Lesson : IHasAttachments
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UnitId { get; set; }
@@ -64,6 +64,10 @@ public class Lesson
public bool HomeworkCheckDismissed { get; set; }
public string? Reflection { get; set; }
public LessonStatus Status { get; set; } = LessonStatus.Planned;
+ /// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
+ /// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
+ /// bei .
+ public List Attachments { get; set; } = [];
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs
index 5a41a93..a3e2af3 100644
--- a/LehrerApp.Core/Models/Workload.cs
+++ b/LehrerApp.Core/Models/Workload.cs
@@ -1,6 +1,6 @@
namespace LehrerApp.Core.Models;
-public class Documentation
+public class Documentation : IHasAttachments
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
@@ -77,6 +77,17 @@ public class DocumentAttachment
public long SizeBytes { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}
+///
+/// Implementiert von jedem Modell mit Datei-Anhängen (aktuell und
+/// ). Anhang-Bytes reisen nicht im JSON-Sync-Ereignis mit, sondern als eigener
+/// Binärtransfer (siehe SyncEventPublisher/EventApplier/AttachmentSyncer in
+/// LehrerApp.Sync) — dieses Interface macht die Erkennung "hat dieses Modell Anhänge?" dort
+/// generisch statt hart auf einen einzelnen Modelltyp verdrahtet.
+///
+public interface IHasAttachments
+{
+ List Attachments { get; }
+}
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
public enum SupportStatus { Active, Completed, Paused }
diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs
index a2eb7dd..94224c1 100644
--- a/LehrerApp.Data.Tests/RepositoryTests.cs
+++ b/LehrerApp.Data.Tests/RepositoryTests.cs
@@ -740,6 +740,25 @@ public sealed class RepositoryTests
Assert.Equal(2, result[2].LessonNumber);
}
+ [Fact]
+ public void LessonRepository_DeleteRaeumtAnhaengeAusDerAttachmentAblageAuf()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new LessonRepository(db);
+ var lesson = new Lesson
+ {
+ UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(),
+ Attachments = [new DocumentAttachment { StorageId = "gbu-1", FileName = "gbu.pdf" }],
+ };
+ db.Attachments.Upload("gbu-1", "gbu.pdf", new MemoryStream([1, 2, 3]));
+ repo.Save(lesson);
+
+ repo.Delete(lesson.Id);
+
+ Assert.Null(db.Lessons.FindById(lesson.Id));
+ Assert.False(db.Attachments.Exists("gbu-1"));
+ }
+
// ── ShorthandCodeRepository ───────────────────────────────────────────────
[Fact]
diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs
index 4223bae..6a53e09 100644
--- a/LehrerApp.Data/Repositories/AllRepositories.cs
+++ b/LehrerApp.Data/Repositories/AllRepositories.cs
@@ -389,7 +389,10 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
public void Delete(Guid id)
{
if (db.Lessons.FindById(id) is { } lesson)
+ {
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
+ foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId);
+ }
db.Lessons.Delete(id);
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
}
diff --git a/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs b/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs
new file mode 100644
index 0000000..afb1515
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs
@@ -0,0 +1,283 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.ViewModels.Groups;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class HazardAssessmentWizardViewModelTests
+{
+ private static HazardAssessmentWizardViewModel Build(HazardAssessment? editing = null) => new(editing, "10c");
+
+ [Fact]
+ public void Neu_StartetAufSchritt0OhneKiOhneEditingKennzeichnung()
+ {
+ var vm = Build();
+
+ Assert.Equal(0, vm.StepIndex);
+ Assert.True(vm.IsBasicsStep);
+ Assert.True(vm.IsFirstStep);
+ Assert.Equal("Gefährdungsbeurteilung erstellen", vm.DialogTitle);
+ Assert.False(vm.CanUseAi);
+ }
+
+ [Fact]
+ public void Next_OhneTitel_BleibtAufSchritt0UndSetztFehler()
+ {
+ var vm = Build();
+
+ vm.NextCommand.Execute(null);
+
+ Assert.Equal(0, vm.StepIndex);
+ Assert.NotEqual("", vm.TitleError);
+ }
+
+ [Fact]
+ public void Next_MitTitel_GehtEinenSchrittWeiterUndAktualisiertStepFlags()
+ {
+ var vm = Build();
+ vm.Title = "Natrium in Wasser";
+
+ vm.NextCommand.Execute(null);
+
+ Assert.Equal(1, vm.StepIndex);
+ Assert.True(vm.IsSubstancesStep);
+ Assert.False(vm.IsFirstStep);
+ Assert.Equal("", vm.TitleError);
+ }
+
+ [Fact]
+ public void Back_AufSchritt0_BleibtAufSchritt0()
+ {
+ var vm = Build();
+
+ vm.BackCommand.Execute(null);
+
+ Assert.Equal(0, vm.StepIndex);
+ }
+
+ [Fact]
+ public void DurchlaufAllerSchritte_LandetAufZusammenfassung()
+ {
+ var vm = Build();
+ vm.Title = "Natrium in Wasser";
+
+ for (var i = 0; i < vm.StepCount - 1; i++) vm.NextCommand.Execute(null);
+
+ Assert.True(vm.IsSummaryStep);
+ Assert.True(vm.IsLastStep);
+
+ vm.NextCommand.Execute(null);
+ Assert.True(vm.IsSummaryStep, "Weiter auf dem letzten Schritt darf nicht darüber hinausgehen.");
+ }
+
+ [Fact]
+ public void AddSubstance_UndRemove_AktualisierenDieListeUndHasSubstances()
+ {
+ var vm = Build();
+ Assert.False(vm.HasSubstances);
+
+ vm.AddSubstanceCommand.Execute(null);
+ Assert.True(vm.HasSubstances);
+ var item = vm.Substances[0];
+ item.Name = "Natrium";
+ item.Amount = "ca. 0,5 g";
+ item.PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true;
+
+ item.RemoveCommand.Execute(null);
+
+ Assert.False(vm.HasSubstances);
+ Assert.Empty(vm.Substances);
+ }
+
+ [Fact]
+ public void AddHazard_TrimmtUndLeertDasEingabefeld()
+ {
+ var vm = Build();
+
+ vm.NewHazard = " Verätzungsgefahr ";
+ vm.AddHazardCommand.Execute(null);
+
+ Assert.Equal(["Verätzungsgefahr"], vm.Hazards);
+ Assert.Equal("", vm.NewHazard);
+ }
+
+ [Fact]
+ public void AddHazard_MitLeeremTextTutNichts()
+ {
+ var vm = Build();
+
+ vm.NewHazard = " ";
+ vm.AddHazardCommand.Execute(null);
+
+ Assert.Empty(vm.Hazards);
+ }
+
+ [Fact]
+ public void RemoveHazard_EntferntGenauDenGenanntenEintrag()
+ {
+ var vm = Build();
+ vm.NewHazard = "A"; vm.AddHazardCommand.Execute(null);
+ vm.NewHazard = "B"; vm.AddHazardCommand.Execute(null);
+
+ vm.RemoveHazardCommand.Execute("A");
+
+ Assert.Equal(["B"], vm.Hazards);
+ }
+
+ [Fact]
+ public void AddProtectiveMeasure_TrimmtUndLeertDasEingabefeld()
+ {
+ var vm = Build();
+
+ vm.NewProtectiveMeasure = " Schutzbrille ";
+ vm.AddProtectiveMeasureCommand.Execute(null);
+
+ Assert.Equal(["Schutzbrille"], vm.ProtectiveMeasures);
+ Assert.Equal("", vm.NewProtectiveMeasure);
+ }
+
+ [Fact]
+ public void Save_OhneTitel_SetztFehlerUndSpringtZurueckAufSchritt0()
+ {
+ var vm = Build();
+ vm.Title = "Etwas";
+ vm.NextCommand.Execute(null); // Schritt 1
+ vm.Title = " ";
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.Null(vm.Result);
+ Assert.Equal(0, vm.StepIndex);
+ Assert.NotEqual("", vm.TitleError);
+ }
+
+ [Fact]
+ public void Save_BautVollstaendigesErgebnisAusAllenSchritten()
+ {
+ var vm = Build();
+ vm.Title = "Natrium in Wasser";
+ vm.GroupLabel = "10c";
+ vm.DateText = "12.09.2026";
+ vm.KindName = "Lehrerversuch";
+ vm.Procedure = "Kurzbeschreibung";
+ vm.AddSubstanceCommand.Execute(null);
+ vm.Substances[0].Name = "Natrium";
+ vm.Substances[0].Amount = "ca. 0,5 g";
+ vm.Substances[0].HStatements = "H260";
+ vm.Substances[0].PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true;
+ vm.NewHazard = "Verpuffung"; vm.AddHazardCommand.Execute(null);
+ vm.NewProtectiveMeasure = "Schutzbrille"; vm.AddProtectiveMeasureCommand.Execute(null);
+ vm.FirstAid = "Wasser spülen";
+ vm.Disposal = "Abreagieren lassen";
+ vm.Notes = "Hinweis";
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.NotNull(vm.Result);
+ var result = vm.Result!;
+ Assert.Equal("Natrium in Wasser", result.Title);
+ Assert.Equal("10c", result.GroupLabel);
+ Assert.Equal(new DateOnly(2026, 9, 12), result.Date);
+ Assert.Equal(ExperimentKind.Lehrerversuch, result.Kind);
+ var substance = Assert.Single(result.Substances);
+ Assert.Equal("Natrium", substance.Name);
+ Assert.Equal([GhsPictogram.Flammable], substance.GhsPictograms);
+ Assert.Equal(["Verpuffung"], result.Hazards);
+ Assert.Equal(["Schutzbrille"], result.ProtectiveMeasures);
+ Assert.Equal("Wasser spülen", result.FirstAid);
+ Assert.Equal("Abreagieren lassen", result.Disposal);
+ Assert.Equal("Hinweis", result.Notes);
+ Assert.False(result.IsAiAssisted);
+ }
+
+ [Fact]
+ public void Konstruktor_MitEditingLaedtAlleFelderVor()
+ {
+ var editing = new HazardAssessment
+ {
+ Title = "Bestehende GBU",
+ GroupLabel = "9a",
+ Date = new DateOnly(2026, 1, 15),
+ Kind = ExperimentKind.Schuelerversuch,
+ Procedure = "Ablauf",
+ Substances = [new HazardSubstance { Name = "Salzsäure", GhsPictograms = [GhsPictogram.Corrosive] }],
+ Hazards = ["Verätzung"],
+ ProtectiveMeasures = ["Handschuhe"],
+ FirstAid = "Erste Hilfe Text",
+ Disposal = "Entsorgungstext",
+ Notes = "Notiz",
+ IsAiAssisted = true,
+ };
+
+ var vm = Build(editing);
+
+ Assert.Equal("Gefährdungsbeurteilung bearbeiten", vm.DialogTitle);
+ Assert.Equal("Bestehende GBU", vm.Title);
+ Assert.Equal("9a", vm.GroupLabel);
+ Assert.Equal("15.01.2026", vm.DateText);
+ Assert.Equal("Schülerversuch", vm.KindName);
+ Assert.Equal("Ablauf", vm.Procedure);
+ var substance = Assert.Single(vm.Substances);
+ Assert.Equal("Salzsäure", substance.Name);
+ Assert.True(substance.PictogramOptions.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
+ Assert.Equal(["Verätzung"], vm.Hazards);
+ Assert.Equal(["Handschuhe"], vm.ProtectiveMeasures);
+ Assert.Equal("Erste Hilfe Text", vm.FirstAid);
+ Assert.Equal("Entsorgungstext", vm.Disposal);
+ Assert.Equal("Notiz", vm.Notes);
+ Assert.True(vm.IsAiAssisted);
+ }
+}
+
+public sealed class ExperimentKindDisplayTests
+{
+ [Theory]
+ [InlineData(ExperimentKind.Lehrerversuch, "Lehrerversuch")]
+ [InlineData(ExperimentKind.Schuelerversuch, "Schülerversuch")]
+ [InlineData(ExperimentKind.Demonstrationsversuch, "Demonstrationsversuch")]
+ public void Label_UndFromLabel_SindZueinanderInvers(ExperimentKind kind, string label)
+ {
+ Assert.Equal(label, ExperimentKindDisplay.Label(kind));
+ Assert.Equal(kind, ExperimentKindDisplay.FromLabel(label));
+ }
+
+ [Fact]
+ public void FromLabel_UnbekanntFaelltAufLehrerversuchZurueck() =>
+ Assert.Equal(ExperimentKind.Lehrerversuch, ExperimentKindDisplay.FromLabel("Unbekannt"));
+
+ [Theory]
+ [InlineData("Schuelerversuch", ExperimentKind.Schuelerversuch)]
+ [InlineData("Demonstrationsversuch", ExperimentKind.Demonstrationsversuch)]
+ [InlineData("etwas anderes", ExperimentKind.Lehrerversuch)]
+ public void FromWireValue_LiestDenUnmarkiertenEnumNamenDerKiAntwort(string wire, ExperimentKind expected) =>
+ Assert.Equal(expected, ExperimentKindDisplay.FromWireValue(wire));
+}
+
+public sealed class GhsPictogramDisplayTests
+{
+ [Theory]
+ [InlineData(GhsPictogram.Explosive, "GHS01")]
+ [InlineData(GhsPictogram.Corrosive, "GHS05")]
+ [InlineData(GhsPictogram.Environmental, "GHS09")]
+ public void Code_LiefertDenErwartetenGhsCode(GhsPictogram value, string code) =>
+ Assert.Equal(code, GhsPictogramDisplay.Code(value));
+
+ [Fact]
+ public void FromCode_IstFallUnabhaengigUndTrimmt() =>
+ Assert.Equal(GhsPictogram.Corrosive, GhsPictogramDisplay.FromCode(" ghs05 "));
+
+ [Fact]
+ public void FromCode_UnbekannterCodeLiefertNull() =>
+ Assert.Null(GhsPictogramDisplay.FromCode("GHS99"));
+
+ [Fact]
+ public void BuildOptions_MarkiertNurDieUebergebenenAlsAusgewaehlt()
+ {
+ var options = GhsPictogramDisplay.BuildOptions([GhsPictogram.Toxic, GhsPictogram.Flammable]);
+
+ Assert.Equal(9, options.Count);
+ Assert.True(options.Single(o => o.Value == GhsPictogram.Toxic).IsSelected);
+ Assert.True(options.Single(o => o.Value == GhsPictogram.Flammable).IsSelected);
+ Assert.False(options.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs
index ac4591a..6cef547 100644
--- a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs
@@ -1,3 +1,4 @@
+using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
@@ -18,7 +19,7 @@ public sealed class LessonDialogViewModelTests
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) =>
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
- slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
+ slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new FakeAttachmentStorage(),
unitId, groupId, "10c", "Chemie", [], [], editing);
/// Nutzer-Feedback: bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere
@@ -92,7 +93,7 @@ public sealed class LessonDialogViewModelTests
{
var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]),
- new FakeTimetableSlots(), NewPeriodSchedule(),
+ new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(),
Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen
Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
@@ -155,7 +156,7 @@ public sealed class LessonDialogViewModelTests
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
- new FakeTimetableSlots(), NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null)
+ new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null)
{
Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45",
};
@@ -179,6 +180,58 @@ public sealed class LessonDialogViewModelTests
Assert.Single(lessons.GetByUnit(unitId));
}
+ [Fact]
+ public void AddAttachment_LaedtHochUndSaveUebernimmtIhnInDasErgebnis()
+ {
+ var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
+ vm.Topic = "Thema"; vm.DateText = "01.09.2025";
+
+ vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3]));
+ vm.SaveCommand.Execute(null);
+
+ Assert.NotNull(vm.Result);
+ var attachment = Assert.Single(vm.Result!.Attachments);
+ Assert.Equal("gbu-natrium.pdf", attachment.FileName);
+ Assert.Equal(3, attachment.SizeBytes);
+ }
+
+ [Fact]
+ public void AddAttachment_ZuGrosseDateiWirdAbgelehnt()
+ {
+ var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
+
+ vm.AddAttachment("riesig.pdf", new MemoryStream(new byte[IAttachmentStorage.MaxSizeBytes + 1]));
+
+ Assert.Empty(vm.Attachments);
+ Assert.NotEqual("", vm.AttachmentError);
+ }
+
+ [Fact]
+ public void RemoveAttachmentCommand_EntferntDenAnhangAusDerListe()
+ {
+ var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
+ vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3]));
+ var item = vm.Attachments[0];
+
+ vm.RemoveAttachmentCommand.Execute(item);
+
+ Assert.Empty(vm.Attachments);
+ }
+
+ [Fact]
+ public void BearbeiteteStunde_LaedtVorhandeneAnhaengeVor()
+ {
+ var editing = new Lesson
+ {
+ Attachments = [new DocumentAttachment { StorageId = "abc", FileName = "gbu.pdf", SizeBytes = 42 }],
+ };
+
+ var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing);
+
+ var attachment = Assert.Single(vm.Attachments);
+ Assert.Equal("gbu.pdf", attachment.FileName);
+ }
+
[Fact]
public void HasHomeworkText_OhneHausaufgabe_IstFalse()
{
@@ -305,7 +358,7 @@ public sealed class LessonDialogViewModelTests
};
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths,
- new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing);
+ new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing);
Assert.True(vm.Phases[0].HasAlternativePath);
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName);
@@ -466,7 +519,7 @@ public sealed class LessonDialogViewModelTests
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
- slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
+ slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
// Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
Assert.Equal("13.01.2099", vm.DateText);
@@ -485,7 +538,7 @@ public sealed class LessonDialogViewModelTests
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
- slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
+ slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
@@ -506,7 +559,7 @@ public sealed class LessonDialogViewModelTests
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
- slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
+ slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
Assert.Equal(3, vm.LessonNumber); // frühere Periode, nicht 4
}
@@ -523,7 +576,7 @@ public sealed class LessonDialogViewModelTests
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 50), End = new TimeOnly(9, 35) }]);
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
- slots, periodSchedule, unitId, groupId, "10c", "Chemie", [], [], null);
+ slots, periodSchedule, new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
Assert.Equal(2, vm.LessonNumber);
Assert.Equal("08:50", vm.StartTimeText);
diff --git a/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs b/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs
index ce1e68b..e860fb5 100644
--- a/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs
+++ b/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs
@@ -154,4 +154,32 @@ public sealed class PdfExportServiceTests
AssertIsPdf(Service.BuildStudentDocumentationPdf(data));
}
+
+ [Fact]
+ public void BuildHazardAssessmentPdf_MitStoffenUndKiHinweis_LiefertGueltigesPdf()
+ {
+ var data = new HazardAssessmentPrintData(
+ "Natrium in Wasser", "10c", "12.09.2026", "Lehrerversuch",
+ "Ein kleines Stück Natrium wird in eine Schale mit Wasser gegeben.",
+ [
+ new HazardSubstancePrintRow("Natrium", "ca. 0,5 g", "GHS02, GHS05",
+ "H260 - Reagiert mit Wasser unter Bildung entzündbarer Gase", "P223, P231+P232, P370+P378"),
+ ],
+ ["Wasserstoffbildung, Verpuffungsgefahr", "Verätzungsgefahr durch Natronlauge"],
+ ["Schutzbrille", "Sicherheitsabstand einhalten", "Abzug/Freiluftversuch"],
+ "Betroffene Stellen mit viel Wasser spülen, Arzt hinzuziehen.",
+ "Reste unter Aufsicht vollständig abreagieren lassen.",
+ "", IsAiAssisted: true);
+
+ AssertIsPdf(Service.BuildHazardAssessmentPdf(data));
+ }
+
+ [Fact]
+ public void BuildHazardAssessmentPdf_OhneStoffeUndOhneKiHinweis_LiefertGueltigesPdf()
+ {
+ var data = new HazardAssessmentPrintData(
+ "Papierchromatographie", "8b", "", "Schülerversuch", "", [], [], [], "", "", "", IsAiAssisted: false);
+
+ AssertIsPdf(Service.BuildHazardAssessmentPdf(data));
+ }
}
diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs
index 5c9c11a..ea67586 100644
--- a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs
@@ -542,4 +542,69 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal("Sitzplan", session.Comment);
Assert.Equal(session.Id, vm.SelectedSession?.Id);
}
+
+ [Fact]
+ public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
+ {
+ var groupId = Guid.NewGuid();
+ var anna = new Student { FirstName = "Anna", LastName = "A" };
+ var ben = new Student { FirstName = "Ben", LastName = "B" };
+ var cara = new Student { FirstName = "Cara", LastName = "C" };
+ var students = new FakeStudents([anna, ben, cara]);
+ var memberships = new FakeMemberships([
+ new GroupMembership { GroupId = groupId, StudentId = anna.Id },
+ new GroupMembership { GroupId = groupId, StudentId = ben.Id },
+ new GroupMembership { GroupId = groupId, StudentId = cara.Id },
+ ]);
+ var plan = new SeatingPlan
+ {
+ GroupId = groupId, Name = "Klausur", Room = "B204", Rows = 1, Columns = 3,
+ Assignments =
+ [
+ new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id },
+ new SeatAssignment { Row = 0, Column = 1, StudentId = ben.Id },
+ new SeatAssignment { Row = 0, Column = 2, StudentId = cara.Id },
+ ],
+ };
+ var plans = new FakeSeatingPlans([plan]);
+ var vm = new SeatingPlanTabViewModel(plans, students, memberships,
+ new FakeSessions([]), new FakeEntries(), new FakeAspects());
+ vm.Initialize(groupId, isReadOnly: false);
+ vm.IsEditMode = true;
+
+ vm.ShuffleSeatsCommand.Execute(null);
+
+ var all = plans.GetByGroup(groupId);
+ Assert.Equal(2, all.Count);
+ var shuffled = Assert.Single(all, p => p.Id != plan.Id);
+ Assert.Equal("B204", shuffled.Room);
+ Assert.Equal(1, shuffled.Rows);
+ Assert.Equal(3, shuffled.Columns);
+ // Dieselben drei Schüler auf denselben drei Koordinaten, nur die Zuordnung untereinander
+ // wurde neu gewürfelt.
+ Assert.Equal(new[] { anna.Id, ben.Id, cara.Id }.OrderBy(id => id),
+ shuffled.Assignments.Select(a => a.StudentId).OrderBy(id => id));
+ Assert.Equal(new[] { (0, 0), (0, 1), (0, 2) },
+ shuffled.Assignments.Select(a => (a.Row, a.Column)).OrderBy(t => t.Item2));
+ // Ursprungsplan bleibt unverändert erhalten.
+ Assert.Equal(3, plans.GetById(plan.Id)!.Assignments.Count);
+ Assert.Equal(vm.SelectedPlan?.Id, shuffled.Id);
+ }
+
+ [Fact]
+ public void ShuffleSeatsCommand_OhneBearbeitungsmodus_NichtAusfuehrbar()
+ {
+ var groupId = Guid.NewGuid();
+ var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
+ var vm = new SeatingPlanTabViewModel(
+ new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
+ new FakeSessions([]), new FakeEntries(), new FakeAspects());
+ vm.Initialize(groupId, isReadOnly: false);
+
+ Assert.False(vm.ShuffleSeatsCommand.CanExecute(null));
+
+ vm.IsEditMode = true;
+
+ Assert.True(vm.ShuffleSeatsCommand.CanExecute(null));
+ }
}
diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs
index 58182c4..abb331b 100644
--- a/LehrerApp.Desktop/Services/AiPlanningService.cs
+++ b/LehrerApp.Desktop/Services/AiPlanningService.cs
@@ -381,6 +381,47 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}
}
+ ///
+ /// Fragt einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser Lesson ab (Nutzerwunsch
+ /// neben 4.2 "Anhänge je Stunde"): die KI erkennt aus Thema/Verlaufsplan das Experiment und
+ /// liefert Stoffe, Gefährdungen, Schutzmaßnahmen etc. als Entwurf zur Weiterbearbeitung im
+ /// Gefährdungsbeurteilungs-Assistenten. Ändert nichts an der Lesson selbst.
+ ///
+ public async Task RequestHazardAssessmentDraftAsync(Unit unit, AiLesson lesson, string token)
+ {
+ var request = new AiHazardAssessmentRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
+
+ using var req = new HttpRequestMessage(HttpMethod.Post, "gbu.php")
+ {
+ Content = JsonContent.Create(request, options: JsonOptions),
+ };
+ req.Headers.Authorization = new("Bearer", token);
+
+ HttpResponseMessage resp;
+ try { resp = await http.SendAsync(req); }
+ catch (HttpRequestException)
+ {
+ throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
+ }
+
+ if (resp.StatusCode == HttpStatusCode.Unauthorized)
+ throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
+ if (resp.StatusCode == (HttpStatusCode)402)
+ throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
+ if (!resp.IsSuccessStatusCode)
+ throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
+
+ try
+ {
+ var result = await resp.Content.ReadFromJsonAsync(JsonOptions);
+ return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
+ }
+ catch (Exception ex) when (ex is not AiBackendException)
+ {
+ throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
+ }
+ }
+
///
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
diff --git a/LehrerApp.Desktop/Services/PdfExportService.cs b/LehrerApp.Desktop/Services/PdfExportService.cs
index 3171982..81e6ce0 100644
--- a/LehrerApp.Desktop/Services/PdfExportService.cs
+++ b/LehrerApp.Desktop/Services/PdfExportService.cs
@@ -73,6 +73,23 @@ public sealed record DocumentationPrintEntry(
bool IsConfidential,
bool IsDraft);
+public sealed record HazardAssessmentPrintData(
+ string Title,
+ string GroupLabel,
+ string DateDisplay,
+ string KindLabel,
+ string Procedure,
+ IReadOnlyList Substances,
+ IReadOnlyList Hazards,
+ IReadOnlyList ProtectiveMeasures,
+ string FirstAid,
+ string Disposal,
+ string Notes,
+ bool IsAiAssisted);
+
+public sealed record HazardSubstancePrintRow(
+ string Name, string Amount, string Pictograms, string HStatements, string PStatements);
+
///
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
/// allen Exporten über . Lebt bewusst in LehrerApp.Desktop statt Core:
@@ -346,8 +363,95 @@ public sealed class PdfExportService
page.Footer().Element(Footer);
})).GeneratePdf();
+ public byte[] BuildHazardAssessmentPdf(HazardAssessmentPrintData data) =>
+ Document.Create(container => container.Page(page =>
+ {
+ page.Size(PageSizes.A4);
+ page.Margin(36);
+ page.DefaultTextStyle(style => style.FontSize(9));
+
+ page.Header().Element(header => Header(header, $"Gefährdungsbeurteilung · {data.Title}",
+ $"{data.GroupLabel} · {data.KindLabel} · {data.DateDisplay}"));
+
+ page.Content().PaddingVertical(10).Column(column =>
+ {
+ column.Spacing(10);
+ if (data.IsAiAssisted) column.Item().Element(AiDisclaimerBanner);
+ if (!string.IsNullOrWhiteSpace(data.Procedure))
+ column.Item().Element(c => TextSection(c, "Durchführung", data.Procedure));
+ if (data.Substances.Count > 0)
+ column.Item().Element(c => SubstanceTable(c, data.Substances));
+ if (data.Hazards.Count > 0)
+ column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards));
+ if (data.ProtectiveMeasures.Count > 0)
+ column.Item().Element(c => BulletSection(c, "Schutzmaßnahmen", data.ProtectiveMeasures));
+ if (!string.IsNullOrWhiteSpace(data.FirstAid))
+ column.Item().Element(c => TextSection(c, "Erste Hilfe", data.FirstAid));
+ if (!string.IsNullOrWhiteSpace(data.Disposal))
+ column.Item().Element(c => TextSection(c, "Entsorgung", data.Disposal));
+ if (!string.IsNullOrWhiteSpace(data.Notes))
+ column.Item().Element(c => TextSection(c, "Weitere Hinweise", data.Notes));
+ });
+
+ page.Footer().Element(Footer);
+ })).GeneratePdf();
+
// ── Bausteine ───────────────────────────────────────────────────────────────
+ private static void AiDisclaimerBanner(IContainer container) =>
+ container.Background(Colors.Orange.Lighten4).Padding(8).Text(
+ "⚠️ Enthält KI-generierte Anteile — kein rechtssicheres Dokument. Vor Verwendung " +
+ "eigenverantwortlich prüfen, insbesondere H-/P-Sätze und Mengenangaben gegen das " +
+ "Sicherheitsdatenblatt.").FontSize(9).FontColor(Colors.Orange.Darken3).SemiBold();
+
+ private static void TextSection(IContainer container, string title, string text) =>
+ container.Column(column =>
+ {
+ column.Item().Text(title).FontSize(11).SemiBold();
+ column.Item().Text(text);
+ });
+
+ private static void BulletSection(IContainer container, string title, IReadOnlyList items) =>
+ container.Column(column =>
+ {
+ column.Item().Text(title).FontSize(11).SemiBold();
+ foreach (var item in items)
+ column.Item().Text($"• {item}");
+ });
+
+ private static void SubstanceTable(IContainer container, IReadOnlyList rows) =>
+ container.Column(column =>
+ {
+ column.Item().Text("Gefahrstoffe").FontSize(11).SemiBold();
+ column.Item().Table(table =>
+ {
+ table.ColumnsDefinition(columns =>
+ {
+ columns.RelativeColumn(2f);
+ columns.RelativeColumn(1f);
+ columns.RelativeColumn(1.3f);
+ columns.RelativeColumn(2f);
+ columns.RelativeColumn(2f);
+ });
+ table.Header(header =>
+ {
+ header.Cell().Element(HeaderCell).Text("Stoff").SemiBold();
+ header.Cell().Element(HeaderCell).Text("Menge").SemiBold();
+ header.Cell().Element(HeaderCell).Text("GHS").SemiBold();
+ header.Cell().Element(HeaderCell).Text("H-Sätze").SemiBold();
+ header.Cell().Element(HeaderCell).Text("P-Sätze").SemiBold();
+ });
+ foreach (var row in rows)
+ {
+ table.Cell().Element(BodyCell).Text(row.Name);
+ table.Cell().Element(BodyCell).Text(row.Amount);
+ table.Cell().Element(BodyCell).Text(row.Pictograms);
+ table.Cell().Element(BodyCell).Text(row.HStatements);
+ table.Cell().Element(BodyCell).Text(row.PStatements);
+ }
+ });
+ });
+
/// Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).
private static void Bar(IContainer container, double percent, string color) =>
container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3)
diff --git a/LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs
new file mode 100644
index 0000000..b00c11b
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs
@@ -0,0 +1,376 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Core.AiPlanning;
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.Services;
+using System.Collections.ObjectModel;
+using System.Globalization;
+
+namespace LehrerApp.Desktop.ViewModels.Groups;
+
+// ── Anzeige: Experimentart, GHS-Piktogramme (Nutzerwunsch neben 4.2 "Anhänge je Stunde") ────────
+
+public static class ExperimentKindDisplay
+{
+ public static string[] Options { get; } = ["Lehrerversuch", "Schülerversuch", "Demonstrationsversuch"];
+
+ public static string Label(ExperimentKind kind) => kind switch
+ {
+ ExperimentKind.Schuelerversuch => "Schülerversuch",
+ ExperimentKind.Demonstrationsversuch => "Demonstrationsversuch",
+ _ => "Lehrerversuch",
+ };
+
+ public static ExperimentKind FromLabel(string? label) => label switch
+ {
+ "Schülerversuch" => ExperimentKind.Schuelerversuch,
+ "Demonstrationsversuch" => ExperimentKind.Demonstrationsversuch,
+ _ => ExperimentKind.Lehrerversuch,
+ };
+
+ /// Für die KI-Antwort, die den unmarkierten Enum-Namen ("Schuelerversuch") statt der
+ /// deutschen Anzeige-Bezeichnung liefert (siehe ai-backend/gbu.php "Antwortformat").
+ public static ExperimentKind FromWireValue(string? value) =>
+ Enum.TryParse(value, ignoreCase: true, out var kind) ? kind : ExperimentKind.Lehrerversuch;
+}
+
+public static class GhsPictogramDisplay
+{
+ private static readonly (GhsPictogram Value, string Code, string Label)[] All =
+ [
+ (GhsPictogram.Explosive, "GHS01", "Explosionsgefährlich"),
+ (GhsPictogram.Flammable, "GHS02", "Entzündbar"),
+ (GhsPictogram.Oxidizing, "GHS03", "Brandfördernd"),
+ (GhsPictogram.CompressedGas, "GHS04", "Gase unter Druck"),
+ (GhsPictogram.Corrosive, "GHS05", "Ätzend"),
+ (GhsPictogram.Toxic, "GHS06", "Giftig"),
+ (GhsPictogram.Harmful, "GHS07", "Gesundheitsschädlich/Reizend"),
+ (GhsPictogram.HealthHazard, "GHS08", "Gesundheitsgefährdend"),
+ (GhsPictogram.Environmental, "GHS09", "Umweltgefährdend"),
+ ];
+
+ public static string Code(GhsPictogram value) => All.First(o => o.Value == value).Code;
+ public static string Label(GhsPictogram value) => All.First(o => o.Value == value).Label;
+
+ public static GhsPictogram? FromCode(string? code)
+ {
+ if (string.IsNullOrWhiteSpace(code)) return null;
+ foreach (var option in All)
+ if (string.Equals(option.Code, code.Trim(), StringComparison.OrdinalIgnoreCase))
+ return option.Value;
+ return null;
+ }
+
+ public static List BuildOptions(IEnumerable? selected = null)
+ {
+ var selectedSet = (selected ?? []).ToHashSet();
+ return All.Select(o => new GhsPictogramOption(o.Value, $"{o.Code} · {o.Label}") { IsSelected = selectedSet.Contains(o.Value) }).ToList();
+ }
+}
+
+public partial class GhsPictogramOption(GhsPictogram value, string label) : ObservableObject
+{
+ public GhsPictogram Value { get; } = value;
+ public string Label { get; } = label;
+ [ObservableProperty] private bool _isSelected;
+}
+
+// ── Zeile im Gefahrstoff-Editor (Wizard-Schritt 2) ───────────────────────────────────────────────
+
+public partial class HazardSubstanceEditItem : ObservableObject
+{
+ [ObservableProperty] private string _name = "";
+ [ObservableProperty] private string _amount = "";
+ [ObservableProperty] private string _hStatements = "";
+ [ObservableProperty] private string _pStatements = "";
+ public ObservableCollection PictogramOptions { get; }
+ public Action? OnRemove { get; set; }
+
+ public HazardSubstanceEditItem(HazardSubstance? source = null)
+ {
+ PictogramOptions = new ObservableCollection(
+ GhsPictogramDisplay.BuildOptions(source?.GhsPictograms));
+ if (source is null) return;
+ Name = source.Name;
+ Amount = source.Amount;
+ HStatements = source.HStatements;
+ PStatements = source.PStatements;
+ }
+
+ [RelayCommand]
+ private void Remove() => OnRemove?.Invoke(this);
+
+ public HazardSubstance ToModel() => new()
+ {
+ Name = Name.Trim(),
+ Amount = Amount.Trim(),
+ HStatements = HStatements.Trim(),
+ PStatements = PStatements.Trim(),
+ GhsPictograms = PictogramOptions.Where(o => o.IsSelected).Select(o => o.Value).ToList(),
+ };
+}
+
+// ── Wizard: Gefährdungsbeurteilung anlegen/bearbeiten ────────────────────────────────────────────
+
+///
+/// Mehrschrittiger Assistent für eine Gefährdungsbeurteilung zu einem Experiment (Nutzerwunsch
+/// neben 4.2 "Anhänge je Stunde"). Persistiert NICHT selbst — der Aufrufer (LessonDialog)
+/// serialisiert zu JSON und hängt es über die bestehende
+/// Anhang-Infrastruktur an die Lesson an (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2).
+/// KI-Unterstützung ist optional: ohne //
+/// bleibt false und der Assistent funktioniert
+/// rein manuell.
+///
+public partial class HazardAssessmentWizardViewModel : ObservableObject
+{
+ private static readonly string[] StepTitlesArray =
+ ["Basisdaten", "Gefahrstoffe", "Gefährdungen & Schutzmaßnahmen", "Erste Hilfe & Entsorgung", "Zusammenfassung"];
+
+ private readonly AiPlanningService? _ai;
+ private readonly AiSettingsService? _aiSettings;
+ private readonly Unit? _unit;
+ private readonly Lesson? _lesson;
+
+ [ObservableProperty] private int _stepIndex;
+ [ObservableProperty] private string _title = "";
+ [ObservableProperty] private string _titleError = "";
+ [ObservableProperty] private string _groupLabel = "";
+ [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
+ [ObservableProperty] private string _kindName = ExperimentKindDisplay.Options[0];
+ [ObservableProperty] private string _procedure = "";
+
+ public ObservableCollection Substances { get; } = [];
+ public bool HasSubstances => Substances.Count > 0;
+
+ [ObservableProperty] private string _newHazard = "";
+ public ObservableCollection Hazards { get; } = [];
+ [ObservableProperty] private string _newProtectiveMeasure = "";
+ public ObservableCollection ProtectiveMeasures { get; } = [];
+
+ [ObservableProperty] private string _firstAid = "";
+ [ObservableProperty] private string _disposal = "";
+ [ObservableProperty] private string _notes = "";
+
+ [ObservableProperty] private bool _isAiAssisted;
+ [ObservableProperty] private bool _isAiBusy;
+ [ObservableProperty] private string _aiError = "";
+ public string AiButtonLabel => IsAiBusy ? "KI fragt..." : "🤖 KI-Entwurf erstellen";
+
+ public bool CanUseAi => _ai is not null && _aiSettings is { Enabled: true, IsLoggedIn: true }
+ && _unit is not null && _lesson is not null;
+
+ public string[] StepTitles => StepTitlesArray;
+ public string StepTitle => StepTitles[StepIndex];
+ public string StepProgressLabel => $"Schritt {StepIndex + 1} von {StepTitles.Length}";
+ public int StepCount => StepTitles.Length;
+ public bool IsFirstStep => StepIndex == 0;
+ public bool IsLastStep => StepIndex == StepTitles.Length - 1;
+ public bool IsBasicsStep => StepIndex == 0;
+ public bool IsSubstancesStep => StepIndex == 1;
+ public bool IsHazardsStep => StepIndex == 2;
+ public bool IsFirstAidStep => StepIndex == 3;
+ public bool IsSummaryStep => StepIndex == 4;
+ public string[] KindOptions => ExperimentKindDisplay.Options;
+ public string DialogTitle => _editing ? "Gefährdungsbeurteilung bearbeiten" : "Gefährdungsbeurteilung erstellen";
+
+ private readonly bool _editing;
+
+ public HazardAssessment? Result { get; private set; }
+
+ public HazardAssessmentWizardViewModel(HazardAssessment? editing, string defaultGroupLabel,
+ AiPlanningService? ai = null, AiSettingsService? aiSettings = null, Unit? unit = null, Lesson? lesson = null)
+ {
+ _ai = ai; _aiSettings = aiSettings; _unit = unit; _lesson = lesson;
+ Substances.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasSubstances));
+ GroupLabel = defaultGroupLabel;
+ _editing = editing is not null;
+ if (editing is null) return;
+
+ Title = editing.Title;
+ GroupLabel = editing.GroupLabel;
+ DateText = editing.Date?.ToString("dd.MM.yyyy") ?? "";
+ KindName = ExperimentKindDisplay.Label(editing.Kind);
+ Procedure = editing.Procedure;
+ foreach (var s in editing.Substances) Substances.Add(NewSubstanceItem(s));
+ foreach (var h in editing.Hazards) Hazards.Add(h);
+ foreach (var m in editing.ProtectiveMeasures) ProtectiveMeasures.Add(m);
+ FirstAid = editing.FirstAid;
+ Disposal = editing.Disposal;
+ Notes = editing.Notes;
+ IsAiAssisted = editing.IsAiAssisted;
+ }
+
+ partial void OnIsAiBusyChanged(bool value) => OnPropertyChanged(nameof(AiButtonLabel));
+
+ partial void OnStepIndexChanged(int value)
+ {
+ OnPropertyChanged(nameof(StepTitle));
+ OnPropertyChanged(nameof(StepProgressLabel));
+ OnPropertyChanged(nameof(IsFirstStep));
+ OnPropertyChanged(nameof(IsLastStep));
+ OnPropertyChanged(nameof(IsBasicsStep));
+ OnPropertyChanged(nameof(IsSubstancesStep));
+ OnPropertyChanged(nameof(IsHazardsStep));
+ OnPropertyChanged(nameof(IsFirstAidStep));
+ OnPropertyChanged(nameof(IsSummaryStep));
+ }
+
+ private HazardSubstanceEditItem NewSubstanceItem(HazardSubstance? source = null)
+ {
+ var item = new HazardSubstanceEditItem(source) { OnRemove = RemoveSubstance };
+ return item;
+ }
+
+ [RelayCommand]
+ private void AddSubstance() => Substances.Add(NewSubstanceItem());
+
+ private void RemoveSubstance(HazardSubstanceEditItem item) => Substances.Remove(item);
+
+ [RelayCommand]
+ private void AddHazard()
+ {
+ if (string.IsNullOrWhiteSpace(NewHazard)) return;
+ Hazards.Add(NewHazard.Trim());
+ NewHazard = "";
+ }
+
+ [RelayCommand]
+ private void RemoveHazard(string? hazard) { if (hazard is not null) Hazards.Remove(hazard); }
+
+ [RelayCommand]
+ private void AddProtectiveMeasure()
+ {
+ if (string.IsNullOrWhiteSpace(NewProtectiveMeasure)) return;
+ ProtectiveMeasures.Add(NewProtectiveMeasure.Trim());
+ NewProtectiveMeasure = "";
+ }
+
+ [RelayCommand]
+ private void RemoveProtectiveMeasure(string? measure) { if (measure is not null) ProtectiveMeasures.Remove(measure); }
+
+ [RelayCommand]
+ private void Next()
+ {
+ if (IsFirstStep && string.IsNullOrWhiteSpace(Title))
+ {
+ TitleError = "Titel erforderlich.";
+ return;
+ }
+ TitleError = "";
+ if (!IsLastStep) StepIndex++;
+ }
+
+ [RelayCommand]
+ private void Back() { if (!IsFirstStep) StepIndex--; }
+
+ ///
+ /// Fragt einen KI-Entwurf zu genau dieser Lesson ab (Thema + Verlaufsplan als Kontext) und
+ /// übernimmt ihn in die Wizard-Felder — überschreibt dabei bewusst den aktuellen Stand, damit
+ /// nach einem erneuten Klick klar ist, was tatsächlich von der KI kommt. Die Lehrkraft prüft
+ /// und passt danach in den einzelnen Schritten an; der Rechtssicherheits-Hinweis wird über
+ /// in den PDF-Export übernommen.
+ ///
+ [RelayCommand]
+ private async Task RequestAiDraft()
+ {
+ if (!CanUseAi) return;
+ var token = _aiSettings!.GetToken();
+ if (token is null)
+ {
+ AiError = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
+ return;
+ }
+
+ AiError = ""; IsAiBusy = true;
+ try
+ {
+ var aiLesson = new AiLesson
+ {
+ Id = _lesson!.Id,
+ Date = _lesson.Date,
+ LessonNumber = _lesson.LessonNumber,
+ Topic = _lesson.Topic,
+ StartTime = _lesson.StartTime,
+ Phases = _lesson.Phases.Select(p => new AiPhaseStep
+ {
+ Name = p.Name, DurationMinutes = p.DurationMinutes,
+ Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand,
+ }).ToList(),
+ };
+
+ var response = await _ai!.RequestHazardAssessmentDraftAsync(_unit!, aiLesson, token);
+ ApplyAiResponse(response);
+ }
+ catch (AiBackendException ex)
+ {
+ AiError = ex.Message;
+ }
+ finally
+ {
+ IsAiBusy = false;
+ }
+ }
+
+ private void ApplyAiResponse(AiHazardAssessmentResponse response)
+ {
+ if (string.IsNullOrWhiteSpace(Title)) Title = _lesson?.Topic ?? "";
+ KindName = ExperimentKindDisplay.Label(ExperimentKindDisplay.FromWireValue(response.ExperimentKind));
+ Procedure = response.Procedure;
+
+ Substances.Clear();
+ foreach (var s in response.Substances)
+ Substances.Add(NewSubstanceItem(new HazardSubstance
+ {
+ Name = s.Name,
+ Amount = s.Amount,
+ HStatements = s.HStatements,
+ PStatements = s.PStatements,
+ GhsPictograms = s.GhsPictograms
+ .Select(GhsPictogramDisplay.FromCode)
+ .Where(p => p.HasValue)
+ .Select(p => p!.Value)
+ .ToList(),
+ }));
+
+ Hazards.Clear();
+ foreach (var h in response.Hazards) Hazards.Add(h);
+ ProtectiveMeasures.Clear();
+ foreach (var m in response.ProtectiveMeasures) ProtectiveMeasures.Add(m);
+ FirstAid = response.FirstAid;
+ Disposal = response.Disposal;
+ IsAiAssisted = true;
+ }
+
+ [RelayCommand]
+ private void Save()
+ {
+ if (string.IsNullOrWhiteSpace(Title))
+ {
+ TitleError = "Titel erforderlich.";
+ StepIndex = 0;
+ return;
+ }
+ TitleError = "";
+
+ DateOnly? date = null;
+ if (!string.IsNullOrWhiteSpace(DateText) &&
+ DateOnly.TryParseExact(DateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
+ date = parsed;
+
+ Result = new HazardAssessment
+ {
+ Title = Title.Trim(),
+ GroupLabel = GroupLabel.Trim(),
+ Date = date,
+ Kind = ExperimentKindDisplay.FromLabel(KindName),
+ Procedure = Procedure.Trim(),
+ Substances = Substances.Select(s => s.ToModel()).ToList(),
+ Hazards = Hazards.ToList(),
+ ProtectiveMeasures = ProtectiveMeasures.ToList(),
+ FirstAid = FirstAid.Trim(),
+ Disposal = Disposal.Trim(),
+ Notes = Notes.Trim(),
+ IsAiAssisted = IsAiAssisted,
+ };
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
index 04e0030..2ce7ec7 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
+using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -655,9 +656,11 @@ public partial class LessonDialogViewModel : ObservableObject
private readonly IAlternativeLessonPathRepository _alternativePaths;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule;
+ private readonly IAttachmentStorage _attachmentStorage;
private readonly Guid _unitId;
private readonly Guid _groupId;
private readonly Lesson? _editingLesson;
+ private readonly List _newlyUploadedStorageIds = [];
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private int? _lessonNumber;
@@ -684,6 +687,10 @@ public partial class LessonDialogViewModel : ObservableObject
public string[] ShorthandSuggestions { get; }
public ObservableCollection Phases { get; } = [];
+ // Anhänge (Material/Arbeitsblätter, Experiment-/Gefährdungsbeurteilungsdokumente).
+ public ObservableCollection Attachments { get; } = [];
+ [ObservableProperty] private string _attachmentError = "";
+
/// Vom Code-Behind gesetzt (Fenster als Owner für den Zuweisen-Dialog): fragt nach dem
/// alternativen Ablauf, dem eine Phase zugeordnet werden soll (Auswahl oder Neuanlage
/// per Combobox-Dialog). null zurückgegeben = abgebrochen.
@@ -713,11 +720,13 @@ public partial class LessonDialogViewModel : ObservableObject
public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes,
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
- PeriodScheduleService periodSchedule, Guid unitId, Guid groupId, string groupName, string subjectName,
+ PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
+ Guid unitId, Guid groupId, string groupName, string subjectName,
List materialSuggestions, List shorthandHistorySuggestions, Lesson? editingLesson)
{
_lessons = lessons; _alternativePaths = alternativePaths;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
+ _attachmentStorage = attachmentStorage;
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}";
@@ -746,6 +755,8 @@ public partial class LessonDialogViewModel : ObservableObject
Reflection = editingLesson.Reflection ?? "";
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
+ foreach (var att in editingLesson.Attachments)
+ Attachments.Add(new AttachmentItem(att.StorageId, att.FileName, att.SizeBytes, att.UploadedAt));
}
else
{
@@ -925,6 +936,39 @@ public partial class LessonDialogViewModel : ObservableObject
_ => "#B71C1C", // Dunkelrot: deutlich überplant
};
+ // ── Anhänge ────────────────────────────────────────────────────────────
+
+ public void AddAttachment(string fileName, Stream content)
+ {
+ AttachmentError = "";
+ if (content.Length > IAttachmentStorage.MaxSizeBytes)
+ {
+ AttachmentError = $"Datei zu groß (max. {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB).";
+ return;
+ }
+ var storageId = _attachmentStorage.Upload(fileName, content);
+ _newlyUploadedStorageIds.Add(storageId);
+ Attachments.Add(new AttachmentItem(storageId, fileName, content.Length, DateTime.UtcNow));
+ }
+
+ public Stream? OpenAttachment(AttachmentItem item) => _attachmentStorage.OpenRead(item.StorageId);
+
+ [RelayCommand]
+ private void RemoveAttachment(AttachmentItem? item)
+ {
+ if (item is null) return;
+ _attachmentStorage.Delete(item.StorageId);
+ _newlyUploadedStorageIds.Remove(item.StorageId);
+ Attachments.Remove(item);
+ }
+
+ /// Vom Code-Behind beim Abbrechen aufgerufen: neu hochgeladene, nie gespeicherte Anhänge
+ /// wieder entfernen, damit keine verwaisten Blobs in der Datenbank zurückbleiben.
+ public void DiscardUnsavedAttachments()
+ {
+ foreach (var id in _newlyUploadedStorageIds) _attachmentStorage.Delete(id);
+ }
+
[RelayCommand]
private void Save()
{
@@ -960,7 +1004,12 @@ public partial class LessonDialogViewModel : ObservableObject
Result.HomeworkCheckDismissed = HomeworkCheckDismissed;
Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim();
Result.Status = LessonStatusDisplay.FromName(StatusName);
+ Result.Attachments = Attachments.Select(a => new DocumentAttachment
+ {
+ StorageId = a.StorageId, FileName = a.FileName, SizeBytes = a.SizeBytes, UploadedAt = a.UploadedAt,
+ }).ToList();
_lessons.Save(Result);
+ _newlyUploadedStorageIds.Clear();
}
}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs
index 7d7652c..1174670 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs
@@ -398,6 +398,40 @@ public partial class SeatingPlanTabViewModel : ObservableObject
ReloadPlans();
}
+ ///
+ /// Klausur-Sitzplan mischen (14, Nutzer-Idee): erzeugt aus dem aktuell gewählten Plan einen
+ /// neuen, unabhängigen Plan mit gleichem Raster/Raum, aber zufällig vertauschten Insassen der
+ /// belegten Plätze (Fisher-Yates via ) — die Platzkoordinaten selbst
+ /// bleiben unverändert, nur wer wo sitzt wird neu gewürfelt. Der ursprüngliche Plan bleibt
+ /// unangetastet erhalten, damit er bei Bedarf für den nächsten regulären Unterricht weiter
+ /// genutzt werden kann.
+ ///
+ [RelayCommand(CanExecute = nameof(CanEditSelected))]
+ private void ShuffleSeats()
+ {
+ if (_currentPlan is null) return;
+
+ var studentIds = _currentPlan.Assignments.Select(a => a.StudentId).ToArray();
+ Random.Shared.Shuffle(studentIds);
+
+ var shuffled = new SeatingPlan
+ {
+ GroupId = _currentPlan.GroupId,
+ Name = $"{_currentPlan.Name} (Klausur gemischt {DateTime.Now:dd.MM. HH:mm})",
+ Room = _currentPlan.Room,
+ Rows = _currentPlan.Rows,
+ Columns = _currentPlan.Columns,
+ ColumnGapWidths = [.. _currentPlan.ColumnGapWidths],
+ IsBoardAtBottom = _currentPlan.IsBoardAtBottom,
+ HiddenSeats = _currentPlan.HiddenSeats.Select(h => new HiddenSeat { Row = h.Row, Column = h.Column }).ToList(),
+ Assignments = _currentPlan.Assignments
+ .Select((a, i) => new SeatAssignment { Row = a.Row, Column = a.Column, StudentId = studentIds[i] })
+ .ToList(),
+ };
+ _plans.Save(shuffled);
+ ReloadPlans(shuffled.Id);
+ }
+
partial void OnIsEditModeChanged(bool value)
{
OnPropertyChanged(nameof(CanEditLayout));
@@ -417,6 +451,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
AddPlanCommand.NotifyCanExecuteChanged();
EditPlanCommand.NotifyCanExecuteChanged();
DeletePlanCommand.NotifyCanExecuteChanged();
+ ShuffleSeatsCommand.NotifyCanExecuteChanged();
}
}
diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
index e1b15ae..791f58d 100644
--- a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
@@ -401,11 +401,17 @@ public partial class DocumentationDialogViewModel : ObservableObject
public class AttachmentItem(string storageId, string fileName, long sizeBytes, DateTime uploadedAt)
{
+ public const string HazardAssessmentSuffix = ".gbu.json";
+
public string StorageId { get; } = storageId;
public string FileName { get; } = fileName;
public long SizeBytes { get; } = sizeBytes;
public DateTime UploadedAt { get; } = uploadedAt;
public string SizeDisplay => $"{SizeBytes / 1024.0:0} KB";
+ /// Strukturierter Gefährdungsbeurteilungs-Anhang (siehe HazardAssessment) statt einer
+ /// beliebigen hochgeladenen Datei — steuert im Anhang-Editor, ob "Öffnen"+"PDF" statt des
+ /// generischen "Speichern" angeboten wird.
+ public bool IsHazardAssessment => FileName.EndsWith(HazardAssessmentSuffix, StringComparison.OrdinalIgnoreCase);
}
public class TagChip(string text)
diff --git a/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml b/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml
new file mode 100644
index 0000000..d36742f
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml.cs
new file mode 100644
index 0000000..1fba421
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml.cs
@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.ViewModels.Groups;
+
+namespace LehrerApp.Desktop.Views.Groups;
+
+public partial class HazardAssessmentWizardDialog : Window
+{
+ public HazardAssessmentWizardDialog() => InitializeComponent();
+
+ private void OnSave(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is HazardAssessmentWizardViewModel vm)
+ {
+ vm.SaveCommand.Execute(null);
+ if (vm.Result is not null) Close(true);
+ }
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml
index f2c3868..cebf0b7 100644
--- a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml
@@ -1,6 +1,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml.cs
index 52b17df..d3660a4 100644
--- a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml.cs
@@ -1,9 +1,11 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
+using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -38,7 +40,136 @@ public partial class LessonDialog : Window
}
}
- private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
+ private void OnCancel(object? s, RoutedEventArgs e)
+ {
+ if (DataContext is LessonDialogViewModel vm) vm.DiscardUnsavedAttachments();
+ Close(false);
+ }
+
+ private async void OnAddAttachment(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not LessonDialogViewModel vm) return;
+
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Anhang auswählen", AllowMultiple = false,
+ });
+ if (files.Count == 0) return;
+
+ await using var stream = await files[0].OpenReadAsync();
+ vm.AddAttachment(files[0].Name, stream);
+ }
+
+ private async void OnOpenAttachment(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not LessonDialogViewModel vm) return;
+ if (sender is not Button { Tag: AttachmentItem item }) return;
+
+ using var source = vm.OpenAttachment(item);
+ if (source is null) return;
+
+ var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Anhang speichern unter", SuggestedFileName = item.FileName,
+ });
+ if (file is null) return;
+
+ await using var target = await file.OpenWriteAsync();
+ await source.CopyToAsync(target);
+ }
+
+ private async void OnCreateHazardAssessment(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not LessonDialogViewModel vm) return;
+
+ var (unit, contextLesson, groupName) = BuildHazardAssessmentContext(vm);
+ var dialogVm = new HazardAssessmentWizardViewModel(null, groupName,
+ App.Services.GetRequiredService(), App.Services.GetRequiredService(),
+ unit, contextLesson);
+ var dialog = new HazardAssessmentWizardDialog { DataContext = dialogVm };
+ var ok = await dialog.ShowDialog(this);
+ if (!ok || dialogVm.Result is null) return;
+
+ UploadHazardAssessment(vm, dialogVm.Result);
+ }
+
+ private async void OnOpenHazardAssessment(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not LessonDialogViewModel vm) return;
+ if (sender is not Button { Tag: AttachmentItem item }) return;
+
+ var editing = await ReadHazardAssessmentAsync(vm, item);
+ if (editing is null) return;
+
+ var (unit, contextLesson, _) = BuildHazardAssessmentContext(vm);
+ var dialogVm = new HazardAssessmentWizardViewModel(editing, editing.GroupLabel,
+ App.Services.GetRequiredService(), App.Services.GetRequiredService(),
+ unit, contextLesson);
+ var dialog = new HazardAssessmentWizardDialog { DataContext = dialogVm };
+ var ok = await dialog.ShowDialog(this);
+ if (!ok || dialogVm.Result is null) return;
+
+ vm.RemoveAttachmentCommand.Execute(item);
+ UploadHazardAssessment(vm, dialogVm.Result);
+ }
+
+ private async void OnPrintHazardAssessment(object? sender, RoutedEventArgs e)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is null || DataContext is not LessonDialogViewModel vm) return;
+ if (sender is not Button { Tag: AttachmentItem item }) return;
+
+ var assessment = await ReadHazardAssessmentAsync(vm, item);
+ if (assessment is null) return;
+
+ var data = new HazardAssessmentPrintData(
+ assessment.Title, assessment.GroupLabel, assessment.Date?.ToString("dd.MM.yyyy") ?? "",
+ ExperimentKindDisplay.Label(assessment.Kind), assessment.Procedure,
+ assessment.Substances.Select(s => new HazardSubstancePrintRow(
+ s.Name, s.Amount, string.Join(", ", s.GhsPictograms.Select(GhsPictogramDisplay.Code)),
+ s.HStatements, s.PStatements)).ToList(),
+ assessment.Hazards, assessment.ProtectiveMeasures, assessment.FirstAid, assessment.Disposal,
+ assessment.Notes, assessment.IsAiAssisted);
+ var pdf = App.Services.GetRequiredService().BuildHazardAssessmentPdf(data);
+
+ await App.Services.GetRequiredService().SaveAsync(topLevel.StorageProvider,
+ ExportFile.Pdf("Gefährdungsbeurteilung als PDF speichern", $"GBU_{assessment.Title}", pdf));
+ }
+
+ /// Baut den Kontext für den Gefährdungsbeurteilungs-Assistenten (KI-Anfrage + Vorbelegung) aus
+ /// dem gerade im Dialog bearbeiteten, ggf. noch nicht gespeicherten Stand — funktioniert damit
+ /// auch beim Neuanlegen einer Stunde, bevor überhaupt gespeichert wurde.
+ private (Unit? Unit, Lesson ContextLesson, string GroupName) BuildHazardAssessmentContext(LessonDialogViewModel vm)
+ {
+ var unit = App.Services.GetRequiredService().GetById(vm.UnitId);
+ var groupName = unit is null ? "" : App.Services.GetRequiredService().GetById(unit.GroupId)?.Name ?? "";
+ var contextLesson = new Lesson { Topic = vm.Topic, Phases = vm.Phases.Select(p => p.ToModel()).ToList() };
+ return (unit, contextLesson, groupName);
+ }
+
+ private static async Task ReadHazardAssessmentAsync(LessonDialogViewModel vm, AttachmentItem item)
+ {
+ using var source = vm.OpenAttachment(item);
+ if (source is null) return null;
+ using var reader = new StreamReader(source);
+ var json = await reader.ReadToEndAsync();
+ return System.Text.Json.JsonSerializer.Deserialize(json);
+ }
+
+ private static void UploadHazardAssessment(LessonDialogViewModel vm, HazardAssessment result)
+ {
+ var json = System.Text.Json.JsonSerializer.Serialize(result);
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ vm.AddAttachment(HazardAssessmentFileName(result.Title), stream);
+ }
+
+ private static string HazardAssessmentFileName(string title)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ var safe = new string(title.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
+ if (safe.Length == 0) safe = "Gefaehrdungsbeurteilung";
+ return safe + AttachmentItem.HazardAssessmentSuffix;
+ }
/// KI-Unterstützung mit Fokus auf genau diese (bereits gespeicherte) Stunde, statt den Umweg
/// über die Einheiten-Übersicht nehmen zu müssen (4.5.22, Nutzer-Feedback). Die Übernahme im
diff --git a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs
index d2fc31e..9080829 100644
--- a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs
@@ -99,6 +99,7 @@ public partial class PlanningTabView : UserControl
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
unitId, groupId, vm.GroupLabel, vm.SubjectName,
materialSuggestions, shorthandHistorySuggestions, editingLesson);
diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
index 229ebae..f6476d9 100644
--- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
@@ -51,6 +51,9 @@
+
diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs
index 3e6ea80..6d6045a 100644
--- a/LehrerApp.Sync.Tests/EventApplierTests.cs
+++ b/LehrerApp.Sync.Tests/EventApplierTests.cs
@@ -180,6 +180,32 @@ public sealed class EventApplierTests
Assert.Empty(handler.Requests);
}
+ /// Regressionstest für die Verallgemeinerung von "nur Documentation" auf
+ /// — Anhänge an einer (Material,
+ /// Gefährdungsbeurteilung) müssen genauso nachgeladen werden.
+ [Fact]
+ public async Task ApplyAsync_LessonMitFehlendemAnhang_LaedtIhnUeberHttpNach()
+ {
+ using var db = NewInMemoryContext();
+ var storageId = Guid.NewGuid().ToString("N");
+ var handler = new FakeHttpMessageHandler(req =>
+ {
+ Assert.Equal($"/api/sync/attachments/{storageId}", req.RequestUri!.AbsolutePath);
+ var encrypted = SyncCrypto.Encrypt([9, 8, 7], Key);
+ return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
+ { Content = new ByteArrayContent(encrypted) };
+ });
+ var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") };
+ var applier = new EventApplier(db, Key, http);
+ var lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Topic = "Brechung" };
+ lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "gbu.pdf", SizeBytes = 3 });
+
+ await applier.ApplyAsync(MakeEvent(nameof(Lesson), lesson.Id.ToString(), "Save", lesson));
+
+ Assert.Single(handler.Requests);
+ Assert.True(db.Attachments.Exists(storageId));
+ }
+
private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new()
{
DeviceId = "companion-1",
diff --git a/LehrerApp.Sync.Tests/SyncEventPublisherTests.cs b/LehrerApp.Sync.Tests/SyncEventPublisherTests.cs
new file mode 100644
index 0000000..89e0098
--- /dev/null
+++ b/LehrerApp.Sync.Tests/SyncEventPublisherTests.cs
@@ -0,0 +1,70 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Sync.Crypto;
+using Xunit;
+
+namespace LehrerApp.Sync.Tests;
+
+public sealed class SyncEventPublisherTests
+{
+ private static readonly byte[] Key = SyncCrypto.GenerateKey();
+
+ [Fact]
+ public void Publish_DocumentationMitAnhang_ReihtIhnZumHochladenEin()
+ {
+ using var temp = new TempEventQueue();
+ var publisher = new SyncEventPublisher(temp.Queue, "desktop-1", Key);
+ var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
+ doc.Attachments.Add(new DocumentAttachment { StorageId = "abc", FileName = "brief.pdf" });
+
+ publisher.Publish(nameof(Documentation), doc.Id.ToString(), "Save", doc);
+
+ Assert.Equal(["abc"], temp.Queue.GetPendingAttachmentUploads());
+ }
+
+ /// Regressionstest für die Verallgemeinerung von "nur Documentation" auf
+ /// — Anhänge an einer müssen genauso in die
+ /// Upload-Warteliste eingereiht werden.
+ [Fact]
+ public void Publish_LessonMitAnhang_ReihtIhnZumHochladenEin()
+ {
+ using var temp = new TempEventQueue();
+ var publisher = new SyncEventPublisher(temp.Queue, "desktop-1", Key);
+ var lesson = new Lesson { UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Topic = "Brechung" };
+ lesson.Attachments.Add(new DocumentAttachment { StorageId = "gbu-1", FileName = "gbu.pdf" });
+
+ publisher.Publish(nameof(Lesson), lesson.Id.ToString(), "Save", lesson);
+
+ Assert.Equal(["gbu-1"], temp.Queue.GetPendingAttachmentUploads());
+ }
+
+ [Fact]
+ public void Publish_EntitaetOhneAnhaenge_ReihtNichtsZumHochladenEin()
+ {
+ using var temp = new TempEventQueue();
+ var publisher = new SyncEventPublisher(temp.Queue, "desktop-1", Key);
+ var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
+
+ publisher.Publish(nameof(Student), student.Id.ToString(), "Save", student);
+
+ Assert.Empty(temp.Queue.GetPendingAttachmentUploads());
+ }
+
+ private sealed class TempEventQueue : IDisposable
+ {
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), $"lehrerapp-sync-tests-publisher-{Guid.NewGuid():N}");
+ public EventQueue Queue { get; }
+
+ public TempEventQueue()
+ {
+ Directory.CreateDirectory(_directory);
+ Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
+ }
+
+ public void Dispose()
+ {
+ Queue.Dispose();
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ }
+ }
+}
diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs
index 4d0d7ad..43e4bcf 100644
--- a/LehrerApp.Sync/EventApplier.cs
+++ b/LehrerApp.Sync/EventApplier.cs
@@ -28,6 +28,11 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
EventQueue? versions = null)
{
private static readonly Dictionary Handlers = BuildHandlers();
+ private static readonly Dictionary> AttachmentDeserializers = new()
+ {
+ [nameof(Documentation)] = json => JsonSerializer.Deserialize(json),
+ [nameof(Lesson)] = json => JsonSerializer.Deserialize(json),
+ };
public async Task ApplyAsync(SyncEvent evt)
{
@@ -41,8 +46,9 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
{
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
handler(db, evt.Operation, evt.EntityId, json);
- if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
- await DownloadMissingAttachmentsAsync(json);
+ if (evt.Operation != "Delete" && http is not null &&
+ AttachmentDeserializers.TryGetValue(evt.EntityType, out var deserialize))
+ await DownloadMissingAttachmentsAsync(deserialize(json));
// evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen
// ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten
// eigenen Push dieser Entität (optimistische Nebenläufigkeitskontrolle, TODO 10.3.4).
@@ -72,13 +78,12 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
}
// Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
- // Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln
- // nachladen. Gegenstück zum Upload in AttachmentSyncer.
- private async Task DownloadMissingAttachmentsAsync(string json)
+ // Anwenden der Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln nachladen.
+ // Gegenstück zum Upload in AttachmentSyncer.
+ private async Task DownloadMissingAttachmentsAsync(IHasAttachments? entity)
{
- var doc = JsonSerializer.Deserialize(json);
- if (doc is null) return;
- foreach (var attachment in doc.Attachments)
+ if (entity is null) return;
+ foreach (var attachment in entity.Attachments)
{
if (db.Attachments.Exists(attachment.StorageId)) continue;
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
diff --git a/LehrerApp.Sync/SyncEventPublisher.cs b/LehrerApp.Sync/SyncEventPublisher.cs
index 298ce1c..d91d1b9 100644
--- a/LehrerApp.Sync/SyncEventPublisher.cs
+++ b/LehrerApp.Sync/SyncEventPublisher.cs
@@ -25,8 +25,8 @@ public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKe
// Anhänge reisen nicht im JSON-Ereignis mit (würde den Kanal für Fotos/Scans aufblähen),
// sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen.
- if (payload is Documentation doc)
- foreach (var attachment in doc.Attachments)
+ if (payload is IHasAttachments withAttachments)
+ foreach (var attachment in withAttachments.Attachments)
queue.QueueAttachmentUpload(attachment.StorageId);
}
}
diff --git a/TODO.md b/TODO.md
index a95b226..8f1b96f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -518,6 +518,83 @@ leer ist) und rollt von dort vorwärts auf den nächsten passenden Wochentag. Fe
Stundenplan-Eintrag für die Gruppe, bleibt "heute" als Fallback erhalten (keine Verhaltens-
änderung für Gruppen ohne Stundenplan).
+**Nachtrag — Anhänge je Stunde (Nutzerwunsch, fachspezifisch):** Chemieunterricht plant mit
+Experimenten, zu denen eine schriftliche Gefährdungsbeurteilung gehört; außerdem gab es bisher
+gar keine Möglichkeit, ein Arbeitsblatt/Material als Datei an eine Stunde zu hängen (die
+`Material`-Spalte im Verlaufsplan ist bewusst nur Freitext, siehe 4.2.2). Statt zweier separater
+Felder ein einziges, generisches `Lesson.Attachments`
+(`List`) — dieselbe Anhang-Infrastruktur, die `Documentation` (5.1) bereits
+nutzt (`IAttachmentStorage`, `LiteAttachmentStorage`), deckt beides ab: Gefährdungsbeurteilungen
+genauso wie normale Arbeitsblätter/Scans. Im Stunden-Dialog
+([LessonDialog.axaml](LehrerApp.Desktop/Views/Groups/LessonDialog.axaml)) neuer Abschnitt
+"Anhänge" nach demselben Muster wie im Dokumentationsdialog (Button "+ Datei" über den
+Avalonia-Dateiauswahldialog, Liste mit Datei/Größe/"Speichern"/"×"). `LessonRepository.Delete`
+räumt beim (harten) Löschen einer Stunde die zugehörigen Anhänge aus der Attachment-Ablage auf,
+analog zu `LiteDbContext.CascadeHardDeleteDocumentation`. Neue Tests: `LessonDialogViewModelTests`
+(Hochladen inkl. Größenlimit, Entfernen, Vorbelegung beim Bearbeiten, Übernahme ins
+Speicherergebnis), `RepositoryTests.LessonRepository_DeleteRaeumtAnhaengeAusDerAttachmentAblageAuf`.
+Eine KI-gestützte Vorformulierung der Gefährdungsbeurteilung (ebenfalls Nutzerwunsch) ist bewusst
+zurückgestellt — dafür bräuchte es einen neuen `ai-backend`-Endpunkt mit eigenem Systemprompt
+(analog zu `explain.php`, 4.5.21) und eine sorgfältig formulierte, gut sichtbare
+Rechtssicherheits-Einschränkung im Dialog; als eigener, separat zu planender Schritt vorgesehen.
+
+**Nachtrag — Gefährdungsbeurteilungs-Assistent (Umsetzung des oben zurückgestellten Schritts):**
+Vor der Umsetzung stand eine Architekturfrage: eigene LiteDB-Entität mit Repository/Sync-Wiring,
+oder etwas Leichtgewichtigeres? Entschieden für Letzteres — die Prüfung, ob Anhang-Dateibytes
+überhaupt zwischen Geräten synchronisieren (siehe Nachtrag zu 10.1.8, dort im selben Zug behoben),
+ergab, dass die eigentliche Lücke eine Ebene tiefer lag, nicht am Fehlen einer eigenen Entität.
+
+**Format:** [`HazardAssessment`](LehrerApp.Core/Models/HazardAssessment.cs) ist ein reines,
+JSON-serialisierbares Modell (Titel, Klassenstufe/Kurs, Datum, Art des Versuchs
+[Lehrer-/Schüler-/Demonstrationsversuch], Durchführung, Gefahrstoffe mit GHS-Piktogrammen/H-/
+P-Sätzen/Menge, Gefährdungen, Schutzmaßnahmen, Erste Hilfe, Entsorgung) — **keine** eigene
+LiteDB-Collection/Repository. Wird als JSON serialisiert und über die bestehende
+Anhang-Infrastruktur an die `Lesson` gehängt, Dateiname endet auf `.gbu.json`
+(`AttachmentItem.IsHazardAssessment`/`HazardAssessmentSuffix`). Bewusst **kein** eingebauter
+Katalog amtlicher H-/P-Satz-Texte im Code — Fehlerrisiko bei sicherheitsrelevanten Angaben, die
+Lehrkraft trägt die Prüfung gegen das Sicherheitsdatenblatt.
+
+**Wizard:** neuer mehrschrittiger Assistent
+([HazardAssessmentWizardDialog.axaml](LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml),
+`HazardAssessmentWizardViewModel`) mit 5 Schritten (Basisdaten, Gefahrstoffe, Gefährdungen &
+Schutzmaßnahmen, Erste Hilfe & Entsorgung, Zusammenfassung). Persistiert nicht selbst — liefert
+nur `Result`, der Aufrufer (`LessonDialog`) serialisiert zu JSON und hängt es über die bereits
+vorhandenen `AddAttachment`/`RemoveAttachmentCommand` an. Im Stunden-Dialog neuer Button
+"🧪 Gefährdungsbeurteilung" neben "+ Datei"; `.gbu.json`-Anhänge zeigen in der Anhangliste
+"Öffnen" (Wizard im Bearbeitungsmodus, ersetzt den Anhang beim Speichern) und "PDF" statt des
+generischen "Speichern".
+
+**KI-Entwurf:** neuer Endpunkt `ai-backend/gbu.php` (gleicher Aufbau wie `explain.php`: Auth,
+Guthabenprüfung, fester Systemprompt, `ai_backend_call_and_charge`) erkennt aus Thema und
+Verlaufsplan der Stunde das Experiment und liefert einen strukturierten Entwurf. Systemprompt
+weist die KI ausdrücklich an, unsichere H-/P-Sätze/Mengenangaben **nicht zu erfinden**, sondern
+mit einem Prüfhinweis zu kennzeichnen, und bei der Einstufung Lehrer-/Schülerversuch im Zweifel
+vorsichtig (Lehrerversuch) zu sein. `AiPlanningService.RequestHazardAssessmentDraftAsync` (neue
+DTOs `AiHazardAssessmentRequest`/`AiHazardAssessmentResponse` in `AiPlanningDtos.cs`) baut den
+Kontext lokal aus dem gerade im Dialog bearbeiteten (auch noch nicht gespeicherten) Stand, nicht
+erst nach dem Speichern. Button "🤖 KI-Entwurf erstellen" im Wizard, direkt daneben ein **fest
+sichtbarer** Rechtssicherheits-Hinweis (nicht erst nach Fehlern eingeblendet): "Kein
+rechtssicheres Dokument — bitte jede Angabe eigenverantwortlich prüfen." `HazardAssessment.
+IsAiAssisted` wird gesetzt und erscheint als deutlich hervorgehobener Warnhinweis im PDF-Export,
+sobald KI-Anteile beteiligt waren.
+
+Deployment-Hinweis: `ai-backend/` hat **kein** Auto-Deploy (anders als `LehrerApp.Api`, siehe
+[docker/README.md](docker/README.md)) — nach diesem Änderungsdurchgang muss `gbu.php` manuell auf
+den PHP-Server hochgeladen werden (siehe [ai-backend/README.md](ai-backend/README.md), Abschnitt
+"Update für bereits deployte Installationen").
+
+**PDF-Export:** `PdfExportService.BuildHazardAssessmentPdf` (neues `HazardAssessmentPrintData`)
+druckt Durchführung, Gefahrstofftabelle, Gefährdungen/Schutzmaßnahmen als Liste, Erste
+Hilfe/Entsorgung/Hinweise — bei `IsAiAssisted` zusätzlich ein auffälliges Warnbanner oben im
+Dokument.
+
+Neue Tests: [HazardAssessmentWizardViewModelTests.cs](LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs)
+(Navigation, Validierung, Gefahrstoff-/Gefährdungs-/Schutzmaßnahmen-Verwaltung, Vorbelegung beim
+Bearbeiten, `ExperimentKindDisplay`/`GhsPictogramDisplay`), zwei neue Fälle in
+`PdfExportServiceTests.cs`. Die HTTP-Anfrage von `RequestHazardAssessmentDraftAsync` selbst ist
+wie bei `RequestExplanationAsync`/`RequestPlanAsync` nicht Teil der automatisierten Tests (braucht
+einen echten Endpunkt, siehe Kommentar in `AiPlanningServiceTests.cs`).
+
### 4.3 Stundenplan
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
@@ -1388,6 +1465,17 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
(Laden, Umschalten inkl. Persistieren, Sperre für belegte Plätze, verhinderte Zuweisung auf
ausgeblendete Plätze, `ShowSeat`/`CanToggleHidden` je nach Modus).
+ **Nachtrag (Nutzerwunsch): Klausur-Sitzplan mischen.** Neuer Button "🎲 Plätze für Klausur
+ mischen" neben Bearbeiten/Löschen — erzeugt aus dem aktuell gewählten Plan einen neuen,
+ unabhängigen Sitzplan mit gleichem Raster/Raum, aber zufällig vertauschten Insassen der
+ belegten Plätze (Fisher-Yates über `Random.Shared.Shuffle`, Platzkoordinaten bleiben
+ unverändert, nur wer wo sitzt wird neu gewürfelt). Der Ursprungsplan bleibt unangetastet
+ erhalten, damit er für den nächsten regulären Unterricht weiter nutzbar ist; der neu
+ angelegte Plan ist wie jeder andere `SeatingPlan`-Datensatz sofort über den bestehenden
+ Sitzplan-PDF-Export (11.4) druckbar, ohne weitere Anpassung. Test:
+ `SeatingPlanViewModelTests.ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan`
+ plus ein `CanExecute`-Test für die Bearbeitungsmodus-Sperre.
+
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,
@@ -1764,6 +1852,26 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
Anhänge nach Anwenden eines `Documentation`-Ereignisses). Original-`StorageId` bleibt beim
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
das immer eine neue Id vergäbe).
+
+ **Nachtrag — Verallgemeinerung auf `IHasAttachments` (Bug, gefunden bei 4.2-Nachtrag
+ "Anhänge je Stunde"):** `SyncEventPublisher.Publish` und `EventApplier` (Gate +
+ Deserialisierung in `DownloadMissingAttachmentsAsync`) waren hart auf den Modelltyp
+ `Documentation` verdrahtet (`if (payload is Documentation doc)` bzw.
+ `evt.EntityType == nameof(Documentation)`). Als `Lesson` ein eigenes `Attachments`-Feld
+ bekam, hätte das bedeutet: die Metadaten synchronisieren (Lesson selbst synchronisiert
+ ohnehin vollständig), aber die eigentlichen Datei-Bytes nie — ein an einem Gerät
+ angehängtes Arbeitsblatt/eine Gefährdungsbeurteilung wäre auf dem zweiten Gerät nur ein
+ Verweis auf eine nicht existierende `StorageId` gewesen. Neues Marker-Interface
+ `IHasAttachments` ([Workload.cs](LehrerApp.Core/Models/Workload.cs), Property
+ `List Attachments`), implementiert von `Documentation` und `Lesson`.
+ `SyncEventPublisher.Publish` prüft jetzt `payload is IHasAttachments` statt des konkreten
+ Typs; `EventApplier` bekommt eine kleine, pro Entitätstyp erweiterbare
+ `AttachmentDeserializers`-Tabelle (analog zur bestehenden `Handlers`-Tabelle) statt der
+ festen `Documentation`-Deserialisierung. `AttachmentSyncer` (Upload-Seite) war bereits
+ generisch (kennt nur `StorageId`s, keinen Entitätstyp) und musste nicht geändert werden.
+ Neue Tests: `EventApplierTests.ApplyAsync_LessonMitFehlendemAnhang_LaedtIhnUeberHttpNach`,
+ neue Datei [SyncEventPublisherTests.cs](LehrerApp.Sync.Tests/SyncEventPublisherTests.cs)
+ (Documentation, Lesson, Entität ohne Anhänge).
- [x] **10.1.9** Schärfere Kollisionskontrolle beim Push (Konzeptgespräch nach dem
Pull-Watermark-Bugfix, siehe Nachtrag zu 10.1.7): statt der bisherigen 30-Sekunden-
Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst") trägt jedes
diff --git a/ai-backend/README.md b/ai-backend/README.md
index 0bc42d4..7090750 100644
--- a/ai-backend/README.md
+++ b/ai-backend/README.md
@@ -79,6 +79,11 @@ Einfach die neue Datei `explain.php` sowie die aktualisierten `db.php` und `plan
(die Abrechnungslogik wurde aus `plan.php` in eine gemeinsame Funktion `ai_backend_call_and_charge`
in `db.php` verschoben, damit `explain.php` sie mitverwenden kann, ohne sie zu duplizieren).
+## Update für bereits deployte Installationen (Gefährdungsbeurteilungs-Entwurf, `gbu.php`)
+
+Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen wie
+`plan.php`/`explain.php`). Einfach die neue Datei `gbu.php` hochladen.
+
## Prompt Caching
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
diff --git a/ai-backend/gbu.php b/ai-backend/gbu.php
new file mode 100644
index 0000000..e40c056
--- /dev/null
+++ b/ai-backend/gbu.php
@@ -0,0 +1,90 @@
+, "groupName": "...", ...weitere Felder als Lesekontext },
+ "lesson": {
+ "topic": "",
+ "phases": [
+ { "name": "...", "activity": "...", "material": "...", "shorthand": "..." }
+ ]
+ }
+}
+
+## Antwortformat
+Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
+{
+ "experimentKind": "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch",
+ "procedure": "",
+ "substances": [
+ {
+ "name": "",
+ "amount": "",
+ "ghsPictograms": ["GHS05", "GHS02"],
+ "hStatements": "",
+ "pStatements": ""
+ }
+ ],
+ "hazards": ["", "..."],
+ "protectiveMeasures": ["", "..."],
+ "firstAid": "",
+ "disposal": ""
+}
+
+Gültige Werte für "ghsPictograms": GHS01 (explosionsgefährlich), GHS02 (entzündbar), GHS03
+(brandfördernd), GHS04 (Gase unter Druck), GHS05 (ätzend), GHS06 (giftig), GHS07
+(gesundheitsschädlich/reizend), GHS08 (gesundheitsgefährdend), GHS09 (umweltgefährdend).
+
+Halte alle Freitexte knapp und konkret (Stichpunkte statt Fließtext, wo sinnvoll). Nenne nur
+Stoffe, die laut Verlaufsplan tatsächlich verwendet werden. Wenn im Verlaufsplan kein erkennbares
+Experiment mit Gefahrstoffen vorkommt, liefere trotzdem das vollständige Schema mit plausiblen,
+aber vorsichtigen Angaben und einer leeren "substances"-Liste.
+PROMPT;
+
+$userContent = json_encode($body);
+$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
+
+$parsed = json_decode($result['content'], true);
+if (!is_array($parsed) || !isset($parsed['procedure'])) {
+ ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
+}
+
+echo json_encode($parsed);