feat: Anhänge je Stunde, Klausur-Sitzplan mischen, Gefährdungsbeurteilungs-Assistent

Lesson bekommt dieselbe Anhang-Infrastruktur wie Documentation (Material,
Arbeitsblätter, Experimentunterlagen), samt Fix einer Sync-Lücke, die Anhang-
Dateibytes bisher nur für Documentation statt generisch übertragen hat
(IHasAttachments). Sitzplan-Tab bekommt einen "Plätze mischen"-Button für
Klausursitzpläne. Neu: mehrschrittiger Gefährdungsbeurteilungs-Assistent mit
optionalem KI-Entwurf (ai-backend/gbu.php) und PDF-Export, Format bewusst als
JSON-Anhang statt eigener Datenbank-Entität. Details und Architekturentscheidungen
in TODO.md (4.2, 7.1.5, 10.1.8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:13:14 +02:00
co-authored by Claude Sonnet 5
parent 331033db5c
commit 038337997f
29 changed files with 1886 additions and 22 deletions
@@ -119,3 +119,37 @@ public class AiExplainResponse
{ {
public string Explanation { get; set; } = ""; public string Explanation { get; set; } = "";
} }
/// <summary>
/// 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.
/// </summary>
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<AiHazardSubstance> Substances { get; set; } = [];
public List<string> Hazards { get; set; } = [];
public List<string> 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<string> GhsPictograms { get; set; } = [];
public string HStatements { get; set; } = "";
public string PStatements { get; set; } = "";
}
+52
View File
@@ -0,0 +1,52 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// 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 <see cref="DocumentAttachment"/>-Infrastruktur an eine <see cref="Lesson"/>
/// 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).
/// </summary>
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<HazardSubstance> Substances { get; set; } = [];
public List<string> Hazards { get; set; } = [];
public List<string> 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<GhsPictogram> 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,
}
+5 -1
View File
@@ -43,7 +43,7 @@ public class Unit
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { 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 Id { get; set; } = Guid.NewGuid();
public Guid UnitId { get; set; } public Guid UnitId { get; set; }
@@ -64,6 +64,10 @@ public class Lesson
public bool HomeworkCheckDismissed { get; set; } public bool HomeworkCheckDismissed { get; set; }
public string? Reflection { get; set; } public string? Reflection { get; set; }
public LessonStatus Status { get; set; } = LessonStatus.Planned; public LessonStatus Status { get; set; } = LessonStatus.Planned;
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
/// bei <see cref="Documentation"/>.
public List<DocumentAttachment> Attachments { get; set; } = [];
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
+12 -1
View File
@@ -1,6 +1,6 @@
namespace LehrerApp.Core.Models; namespace LehrerApp.Core.Models;
public class Documentation public class Documentation : IHasAttachments
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; } public Guid StudentId { get; set; }
@@ -77,6 +77,17 @@ public class DocumentAttachment
public long SizeBytes { get; set; } public long SizeBytes { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow; public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary>
/// Implementiert von jedem Modell mit Datei-Anhängen (aktuell <see cref="Documentation"/> und
/// <see cref="Lesson"/>). Anhang-Bytes reisen nicht im JSON-Sync-Ereignis mit, sondern als eigener
/// Binärtransfer (siehe <c>SyncEventPublisher</c>/<c>EventApplier</c>/<c>AttachmentSyncer</c> in
/// LehrerApp.Sync) — dieses Interface macht die Erkennung "hat dieses Modell Anhänge?" dort
/// generisch statt hart auf einen einzelnen Modelltyp verdrahtet.
/// </summary>
public interface IHasAttachments
{
List<DocumentAttachment> Attachments { get; }
}
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben. // 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 DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
public enum SupportStatus { Active, Completed, Paused } public enum SupportStatus { Active, Completed, Paused }
+19
View File
@@ -740,6 +740,25 @@ public sealed class RepositoryTests
Assert.Equal(2, result[2].LessonNumber); 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 ─────────────────────────────────────────────── // ── ShorthandCodeRepository ───────────────────────────────────────────────
[Fact] [Fact]
@@ -389,7 +389,10 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
public void Delete(Guid id) public void Delete(Guid id)
{ {
if (db.Lessons.FindById(id) is { } lesson) if (db.Lessons.FindById(id) is { } lesson)
{
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId);
}
db.Lessons.Delete(id); db.Lessons.Delete(id);
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null); db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
} }
@@ -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);
}
}
@@ -1,3 +1,4 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
@@ -18,7 +19,7 @@ public sealed class LessonDialogViewModelTests
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null, private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) => FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) =>
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new FakeAttachmentStorage(),
unitId, groupId, "10c", "Chemie", [], [], editing); unitId, groupId, "10c", "Chemie", [], [], editing);
/// Nutzer-Feedback: bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere /// 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 codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]), 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 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); Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
@@ -155,7 +156,7 @@ public sealed class LessonDialogViewModelTests
var groupId = Guid.NewGuid(); var groupId = Guid.NewGuid();
var lessons = new FakeLessons(); var lessons = new FakeLessons();
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), 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", Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45",
}; };
@@ -179,6 +180,58 @@ public sealed class LessonDialogViewModelTests
Assert.Single(lessons.GetByUnit(unitId)); 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] [Fact]
public void HasHomeworkText_OhneHausaufgabe_IstFalse() public void HasHomeworkText_OhneHausaufgabe_IstFalse()
{ {
@@ -305,7 +358,7 @@ public sealed class LessonDialogViewModelTests
}; };
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths, 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.True(vm.Phases[0].HasAlternativePath);
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName); 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 }); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), 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. // Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
Assert.Equal("13.01.2099", vm.DateText); 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 }); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), 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"); var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today)); 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 }); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), 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 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) }]); 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([]), 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(2, vm.LessonNumber);
Assert.Equal("08:50", vm.StartTimeText); Assert.Equal("08:50", vm.StartTimeText);
@@ -154,4 +154,32 @@ public sealed class PdfExportServiceTests
AssertIsPdf(Service.BuildStudentDocumentationPdf(data)); 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));
}
} }
@@ -542,4 +542,69 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal("Sitzplan", session.Comment); Assert.Equal("Sitzplan", session.Comment);
Assert.Equal(session.Id, vm.SelectedSession?.Id); 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));
}
} }
@@ -381,6 +381,47 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
} }
} }
/// <summary>
/// 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.
/// </summary>
public async Task<AiHazardAssessmentResponse> 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<AiHazardAssessmentResponse>(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.");
}
}
/// <summary> /// <summary>
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar /// 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 /// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
@@ -73,6 +73,23 @@ public sealed record DocumentationPrintEntry(
bool IsConfidential, bool IsConfidential,
bool IsDraft); bool IsDraft);
public sealed record HazardAssessmentPrintData(
string Title,
string GroupLabel,
string DateDisplay,
string KindLabel,
string Procedure,
IReadOnlyList<HazardSubstancePrintRow> Substances,
IReadOnlyList<string> Hazards,
IReadOnlyList<string> ProtectiveMeasures,
string FirstAid,
string Disposal,
string Notes,
bool IsAiAssisted);
public sealed record HazardSubstancePrintRow(
string Name, string Amount, string Pictograms, string HStatements, string PStatements);
/// <summary> /// <summary>
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei /// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
/// allen Exporten über <see cref="ExportService"/>. Lebt bewusst in LehrerApp.Desktop statt Core: /// allen Exporten über <see cref="ExportService"/>. Lebt bewusst in LehrerApp.Desktop statt Core:
@@ -346,8 +363,95 @@ public sealed class PdfExportService
page.Footer().Element(Footer); page.Footer().Element(Footer);
})).GeneratePdf(); })).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 ─────────────────────────────────────────────────────────────── // ── 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<string> 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<HazardSubstancePrintRow> 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);
}
});
});
/// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary> /// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary>
private static void Bar(IContainer container, double percent, string color) => private static void Bar(IContainer container, double percent, string color) =>
container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3) container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3)
@@ -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<ExperimentKind>(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<GhsPictogramOption> BuildOptions(IEnumerable<GhsPictogram>? 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<GhsPictogramOption> PictogramOptions { get; }
public Action<HazardSubstanceEditItem>? OnRemove { get; set; }
public HazardSubstanceEditItem(HazardSubstance? source = null)
{
PictogramOptions = new ObservableCollection<GhsPictogramOption>(
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 ────────────────────────────────────────────
/// <summary>
/// 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 <see cref="Result"/> 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 <paramref name="ai"/>/<paramref name="unit"/>/
/// <paramref name="lesson"/> bleibt <see cref="CanUseAi"/> false und der Assistent funktioniert
/// rein manuell.
/// </summary>
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<HazardSubstanceEditItem> Substances { get; } = [];
public bool HasSubstances => Substances.Count > 0;
[ObservableProperty] private string _newHazard = "";
public ObservableCollection<string> Hazards { get; } = [];
[ObservableProperty] private string _newProtectiveMeasure = "";
public ObservableCollection<string> 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--; }
/// <summary>
/// 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
/// <see cref="IsAiAssisted"/> in den PDF-Export übernommen.
/// </summary>
[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,
};
}
}
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -655,9 +656,11 @@ public partial class LessonDialogViewModel : ObservableObject
private readonly IAlternativeLessonPathRepository _alternativePaths; private readonly IAlternativeLessonPathRepository _alternativePaths;
private readonly ITimetableSlotRepository _timetableSlots; private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule; private readonly PeriodScheduleService _periodSchedule;
private readonly IAttachmentStorage _attachmentStorage;
private readonly Guid _unitId; private readonly Guid _unitId;
private readonly Guid _groupId; private readonly Guid _groupId;
private readonly Lesson? _editingLesson; private readonly Lesson? _editingLesson;
private readonly List<string> _newlyUploadedStorageIds = [];
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private int? _lessonNumber; [ObservableProperty] private int? _lessonNumber;
@@ -684,6 +687,10 @@ public partial class LessonDialogViewModel : ObservableObject
public string[] ShorthandSuggestions { get; } public string[] ShorthandSuggestions { get; }
public ObservableCollection<PhaseStepEditItem> Phases { get; } = []; public ObservableCollection<PhaseStepEditItem> Phases { get; } = [];
// Anhänge (Material/Arbeitsblätter, Experiment-/Gefährdungsbeurteilungsdokumente).
public ObservableCollection<AttachmentItem> Attachments { get; } = [];
[ObservableProperty] private string _attachmentError = "";
/// Vom Code-Behind gesetzt (Fenster als Owner für den Zuweisen-Dialog): fragt nach dem /// 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 /// alternativen Ablauf, dem eine Phase zugeordnet werden soll (Auswahl oder Neuanlage
/// per Combobox-Dialog). null zurückgegeben = abgebrochen. /// per Combobox-Dialog). null zurückgegeben = abgebrochen.
@@ -713,11 +720,13 @@ public partial class LessonDialogViewModel : ObservableObject
public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes, public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes,
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots, 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<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson) List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
{ {
_lessons = lessons; _alternativePaths = alternativePaths; _lessons = lessons; _alternativePaths = alternativePaths;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attachmentStorage = attachmentStorage;
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson; _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName) GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}"; ? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}";
@@ -746,6 +755,8 @@ public partial class LessonDialogViewModel : ObservableObject
Reflection = editingLesson.Reflection ?? ""; Reflection = editingLesson.Reflection ?? "";
StatusName = LessonStatusDisplay.ToName(editingLesson.Status); StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
foreach (var p in editingLesson.Phases) AddPhaseInternal(p); 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 else
{ {
@@ -925,6 +936,39 @@ public partial class LessonDialogViewModel : ObservableObject
_ => "#B71C1C", // Dunkelrot: deutlich überplant _ => "#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] [RelayCommand]
private void Save() private void Save()
{ {
@@ -960,7 +1004,12 @@ public partial class LessonDialogViewModel : ObservableObject
Result.HomeworkCheckDismissed = HomeworkCheckDismissed; Result.HomeworkCheckDismissed = HomeworkCheckDismissed;
Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim(); Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim();
Result.Status = LessonStatusDisplay.FromName(StatusName); 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); _lessons.Save(Result);
_newlyUploadedStorageIds.Clear();
} }
} }
@@ -398,6 +398,40 @@ public partial class SeatingPlanTabViewModel : ObservableObject
ReloadPlans(); ReloadPlans();
} }
/// <summary>
/// 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 <see cref="Random.Shared"/>) — 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.
/// </summary>
[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) partial void OnIsEditModeChanged(bool value)
{ {
OnPropertyChanged(nameof(CanEditLayout)); OnPropertyChanged(nameof(CanEditLayout));
@@ -417,6 +451,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
AddPlanCommand.NotifyCanExecuteChanged(); AddPlanCommand.NotifyCanExecuteChanged();
EditPlanCommand.NotifyCanExecuteChanged(); EditPlanCommand.NotifyCanExecuteChanged();
DeletePlanCommand.NotifyCanExecuteChanged(); DeletePlanCommand.NotifyCanExecuteChanged();
ShuffleSeatsCommand.NotifyCanExecuteChanged();
} }
} }
@@ -401,11 +401,17 @@ public partial class DocumentationDialogViewModel : ObservableObject
public class AttachmentItem(string storageId, string fileName, long sizeBytes, DateTime uploadedAt) public class AttachmentItem(string storageId, string fileName, long sizeBytes, DateTime uploadedAt)
{ {
public const string HazardAssessmentSuffix = ".gbu.json";
public string StorageId { get; } = storageId; public string StorageId { get; } = storageId;
public string FileName { get; } = fileName; public string FileName { get; } = fileName;
public long SizeBytes { get; } = sizeBytes; public long SizeBytes { get; } = sizeBytes;
public DateTime UploadedAt { get; } = uploadedAt; public DateTime UploadedAt { get; } = uploadedAt;
public string SizeDisplay => $"{SizeBytes / 1024.0:0} KB"; 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) public class TagChip(string text)
@@ -0,0 +1,203 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.HazardAssessmentWizardDialog"
x:DataType="vm:HazardAssessmentWizardViewModel"
Title="{Binding DialogTitle}"
Width="720" Height="720" MinWidth="600" MinHeight="480"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,10">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.7">
<Run Text="{Binding StepProgressLabel}"/>
<Run Text=" · "/>
<Run Text="{Binding StepTitle}" FontWeight="SemiBold"/>
</TextBlock>
</StackPanel>
<Border Grid.Row="1" IsVisible="{Binding CanUseAi}" Background="#FFF3E0" CornerRadius="6"
Padding="10" Margin="0,0,0,10">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock FontSize="12" TextWrapping="Wrap">
<Run Text="🤖 KI-Entwurf auf Basis von Thema und Verlaufsplan dieser Stunde." FontWeight="SemiBold"/>
<LineBreak/>
<Run Text="⚠️ Kein rechtssicheres Dokument — bitte jede Angabe (insbesondere H-/P-Sätze und Mengen) eigenverantwortlich gegen das Sicherheitsdatenblatt prüfen, bevor du die Gefährdungsbeurteilung verwendest."/>
</TextBlock>
<TextBlock Text="{Binding AiError}" Foreground="Red" FontSize="11"
IsVisible="{Binding AiError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Button Grid.Column="1" Content="{Binding AiButtonLabel}" Command="{Binding RequestAiDraftCommand}"
IsEnabled="{Binding !IsAiBusy}" VerticalAlignment="Top" Margin="10,0,0,0"/>
</Grid>
</Border>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="14" Margin="0,0,12,0">
<!-- Schritt 1: Basisdaten -->
<StackPanel Spacing="10" IsVisible="{Binding IsBasicsStep}">
<StackPanel Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}" PlaceholderText="z.B. Natrium in Wasser"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Klassenstufe/Kurs" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding GroupLabel}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Datum" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Text="Art des Versuchs" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding KindOptions}" SelectedItem="{Binding KindName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Durchführung (Kurzbeschreibung)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Procedure}" AcceptsReturn="True" TextWrapping="Wrap" Height="90"/>
</StackPanel>
</StackPanel>
<!-- Schritt 2: Gefahrstoffe -->
<StackPanel Spacing="10" IsVisible="{Binding IsSubstancesStep}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Verwendete Gefahrstoffe" FontSize="13" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content=" Stoff" Command="{Binding AddSubstanceCommand}"/>
</Grid>
<TextBlock Text="Noch keine Gefahrstoffe erfasst." Classes="emptyhint"
IsVisible="{Binding !HasSubstances}"/>
<ItemsControl ItemsSource="{Binding Substances}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HazardSubstanceEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,10">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,140,Auto">
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="Stoffname"
Margin="0,0,8,0"/>
<TextBox Grid.Column="1" Text="{Binding Amount}" PlaceholderText="Menge, z.B. ca. 2 g"
Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="✕" Command="{Binding RemoveCommand}"
ToolTip.Tip="Stoff entfernen"/>
</Grid>
<ItemsControl ItemsSource="{Binding PictogramOptions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="10" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GhsPictogramOption">
<CheckBox Content="{Binding Label}" IsChecked="{Binding IsSelected}" FontSize="12"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="H-Sätze" FontSize="11" Opacity="0.6"/>
<TextBox Text="{Binding HStatements}" PlaceholderText="z.B. H314, H290"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock Text="P-Sätze" FontSize="11" Opacity="0.6"/>
<TextBox Text="{Binding PStatements}" PlaceholderText="z.B. P280, P305+P351+P338"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Schritt 3: Gefährdungen & Schutzmaßnahmen -->
<StackPanel Spacing="14" IsVisible="{Binding IsHazardsStep}">
<StackPanel Spacing="6">
<TextBlock Text="Identifizierte Gefährdungen" FontSize="13" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding Hazards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="×" FontSize="13" Padding="7,2"
Command="{Binding $parent[ItemsControl].((vm:HazardAssessmentWizardViewModel)DataContext).RemoveHazardCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewHazard}" PlaceholderText="z.B. Verätzungsgefahr an Haut/Augen"/>
<Button Grid.Column="2" Content="" Command="{Binding AddHazardCommand}"/>
</Grid>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Schutzmaßnahmen" FontSize="13" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding ProtectiveMeasures}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="×" FontSize="13" Padding="7,2"
Command="{Binding $parent[ItemsControl].((vm:HazardAssessmentWizardViewModel)DataContext).RemoveProtectiveMeasureCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewProtectiveMeasure}" PlaceholderText="z.B. Schutzbrille, Handschuhe, Abzug"/>
<Button Grid.Column="2" Content="" Command="{Binding AddProtectiveMeasureCommand}"/>
</Grid>
</StackPanel>
</StackPanel>
<!-- Schritt 4: Erste Hilfe & Entsorgung -->
<StackPanel Spacing="10" IsVisible="{Binding IsFirstAidStep}">
<StackPanel Spacing="4">
<TextBlock Text="Erste Hilfe" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding FirstAid}" AcceptsReturn="True" TextWrapping="Wrap" Height="90"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Entsorgung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Disposal}" AcceptsReturn="True" TextWrapping="Wrap" Height="70"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Weitere Hinweise" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Notes}" AcceptsReturn="True" TextWrapping="Wrap" Height="60"/>
</StackPanel>
</StackPanel>
<!-- Schritt 5: Zusammenfassung -->
<StackPanel Spacing="8" IsVisible="{Binding IsSummaryStep}">
<TextBlock Text="{Binding Title}" FontSize="15" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.7">
<Run Text="{Binding GroupLabel}"/>
<Run Text=" · "/>
<Run Text="{Binding KindName}"/>
</TextBlock>
<TextBlock Text="{Binding Procedure}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Substances.Count, StringFormat='{}{0} Gefahrstoff(e) erfasst'}" FontSize="12"/>
<TextBlock Text="Alle Angaben vor dem Einsatz noch einmal gegenprüfen." FontSize="12" Opacity="0.7"
IsVisible="{Binding IsAiAssisted}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,*,Auto,8,Auto,8,Auto" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="Zurück" Command="{Binding BackCommand}"
IsVisible="{Binding !IsFirstStep}"/>
<Button Grid.Column="4" Content="Weiter" Command="{Binding NextCommand}"
IsVisible="{Binding !IsLastStep}"/>
<Button Grid.Column="6" Content="Speichern" Click="OnSave" IsVisible="{Binding IsLastStep}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class 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);
}
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vms="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Groups.LessonDialog" x:Class="LehrerApp.Desktop.Views.Groups.LessonDialog"
x:DataType="vm:LessonDialogViewModel" x:DataType="vm:LessonDialogViewModel"
Title="{Binding DialogTitle}" Title="{Binding DialogTitle}"
@@ -139,6 +140,43 @@
<TextBox Text="{Binding Reflection}" AcceptsReturn="True" Height="56" TextWrapping="Wrap" <TextBox Text="{Binding Reflection}" AcceptsReturn="True" Height="56" TextWrapping="Wrap"
PlaceholderText="Nach der Stunde: was lief gut, was nicht?"/> PlaceholderText="Nach der Stunde: was lief gut, was nicht?"/>
</StackPanel> </StackPanel>
<Separator Margin="0,4"/>
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Anhänge (Material, Arbeitsblätter, Experiment-/Gefährdungsbeurteilung)"
FontSize="12" Opacity="0.7" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
<Button Content="🧪 Gefährdungsbeurteilung" FontSize="12" Padding="8,4" Click="OnCreateHazardAssessment"/>
<Button Content=" Datei" FontSize="12" Padding="8,4" Click="OnAddAttachment"/>
</StackPanel>
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vms:AttachmentItem">
<Grid ColumnDefinitions="*,Auto,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding FileName}" VerticalAlignment="Center" FontSize="13"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding SizeDisplay}" VerticalAlignment="Center"
FontSize="11" Opacity="0.5" Margin="6,0"/>
<Button Grid.Column="2" Content="Öffnen" FontSize="11" Padding="7,2" Margin="0,0,4,0"
Click="OnOpenHazardAssessment" Tag="{Binding}" IsVisible="{Binding IsHazardAssessment}"/>
<Button Grid.Column="2" Content="Speichern" FontSize="11" Padding="7,2" Margin="0,0,4,0"
Click="OnOpenAttachment" Tag="{Binding}" IsVisible="{Binding !IsHazardAssessment}"/>
<Button Grid.Column="3" Content="PDF" FontSize="11" Padding="7,2" Margin="0,0,4,0"
Click="OnPrintHazardAssessment" Tag="{Binding}"
IsVisible="{Binding IsHazardAssessment}"/>
<Button Grid.Column="4" Content="×" FontSize="13" Padding="7,2"
Command="{Binding $parent[ItemsControl].((vm:LessonDialogViewModel)DataContext).RemoveAttachmentCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding AttachmentError}" Foreground="Red" FontSize="11"
IsVisible="{Binding AttachmentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -1,9 +1,11 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups; 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<AiPlanningService>(), App.Services.GetRequiredService<AiSettingsService>(),
unit, contextLesson);
var dialog = new HazardAssessmentWizardDialog { DataContext = dialogVm };
var ok = await dialog.ShowDialog<bool>(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<AiPlanningService>(), App.Services.GetRequiredService<AiSettingsService>(),
unit, contextLesson);
var dialog = new HazardAssessmentWizardDialog { DataContext = dialogVm };
var ok = await dialog.ShowDialog<bool>(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<PdfExportService>().BuildHazardAssessmentPdf(data);
await App.Services.GetRequiredService<ExportService>().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<IUnitRepository>().GetById(vm.UnitId);
var groupName = unit is null ? "" : App.Services.GetRequiredService<IGroupRepository>().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<HazardAssessment?> 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<HazardAssessment>(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 /// 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 /// über die Einheiten-Übersicht nehmen zu müssen (4.5.22, Nutzer-Feedback). Die Übernahme im
@@ -99,6 +99,7 @@ public partial class PlanningTabView : UserControl
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(), App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
App.Services.GetRequiredService<ITimetableSlotRepository>(), App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<PeriodScheduleService>(), App.Services.GetRequiredService<PeriodScheduleService>(),
App.Services.GetRequiredService<IAttachmentStorage>(),
unitId, groupId, vm.GroupLabel, vm.SubjectName, unitId, groupId, vm.GroupLabel, vm.SubjectName,
materialSuggestions, shorthandHistorySuggestions, editingLesson); materialSuggestions, shorthandHistorySuggestions, editingLesson);
@@ -51,6 +51,9 @@
<Button Grid.Column="2" Content="Löschen" Command="{Binding DeletePlanCommand}" <Button Grid.Column="2" Content="Löschen" Command="{Binding DeletePlanCommand}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</Grid> </Grid>
<Button Content="🎲 Plätze für Klausur mischen" Command="{Binding ShuffleSeatsCommand}"
HorizontalAlignment="Stretch" IsVisible="{Binding HasSelectedPlan}"
ToolTip.Tip="Legt einen neuen, unabhängigen Sitzplan mit demselben Raster an, in dem die belegten Plätze zufällig neu vertauscht sind. Der ursprüngliche Plan bleibt unverändert erhalten."/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
+26
View File
@@ -180,6 +180,32 @@ public sealed class EventApplierTests
Assert.Empty(handler.Requests); Assert.Empty(handler.Requests);
} }
/// Regressionstest für die Verallgemeinerung von "nur Documentation" auf
/// <see cref="IHasAttachments"/> — Anhänge an einer <see cref="Lesson"/> (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() private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new()
{ {
DeviceId = "companion-1", DeviceId = "companion-1",
@@ -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
/// <see cref="IHasAttachments"/> — Anhänge an einer <see cref="Lesson"/> 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);
}
}
}
+13 -8
View File
@@ -28,6 +28,11 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
EventQueue? versions = null) EventQueue? versions = null)
{ {
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers(); private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
private static readonly Dictionary<string, Func<string, IHasAttachments?>> AttachmentDeserializers = new()
{
[nameof(Documentation)] = json => JsonSerializer.Deserialize<Documentation>(json),
[nameof(Lesson)] = json => JsonSerializer.Deserialize<Lesson>(json),
};
public async Task ApplyAsync(SyncEvent evt) 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); var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
handler(db, evt.Operation, evt.EntityId, json); handler(db, evt.Operation, evt.EntityId, json);
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null) if (evt.Operation != "Delete" && http is not null &&
await DownloadMissingAttachmentsAsync(json); AttachmentDeserializers.TryGetValue(evt.EntityType, out var deserialize))
await DownloadMissingAttachmentsAsync(deserialize(json));
// evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen // evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen
// ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten // ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten
// eigenen Push dieser Entität (optimistische Nebenläufigkeitskontrolle, TODO 10.3.4). // 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 // Anhang-Bytes reisen nicht im JSON-Ereignis mit (siehe SyncEventPublisher) - nach dem
// Anwenden der Documentation-Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln // Anwenden der Metadaten fehlende, lokal noch nicht vorhandene Anhänge einzeln nachladen.
// nachladen. Gegenstück zum Upload in AttachmentSyncer. // Gegenstück zum Upload in AttachmentSyncer.
private async Task DownloadMissingAttachmentsAsync(string json) private async Task DownloadMissingAttachmentsAsync(IHasAttachments? entity)
{ {
var doc = JsonSerializer.Deserialize<Documentation>(json); if (entity is null) return;
if (doc is null) return; foreach (var attachment in entity.Attachments)
foreach (var attachment in doc.Attachments)
{ {
if (db.Attachments.Exists(attachment.StorageId)) continue; if (db.Attachments.Exists(attachment.StorageId)) continue;
var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}"); var resp = await http!.GetAsync($"/api/sync/attachments/{attachment.StorageId}");
+2 -2
View File
@@ -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), // 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. // sondern als eigener Binärtransfer über AttachmentSyncer — hier nur zur Warteliste hinzufügen.
if (payload is Documentation doc) if (payload is IHasAttachments withAttachments)
foreach (var attachment in doc.Attachments) foreach (var attachment in withAttachments.Attachments)
queue.QueueAttachmentUpload(attachment.StorageId); queue.QueueAttachmentUpload(attachment.StorageId);
} }
} }
+108
View File
@@ -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- Stundenplan-Eintrag für die Gruppe, bleibt "heute" als Fallback erhalten (keine Verhaltens-
änderung für Gruppen ohne Stundenplan). ä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<DocumentAttachment>`) — 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 ### 4.3 Stundenplan
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository — - [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
[Planning.cs](LehrerApp.Core/Models/Planning.cs), [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 (Laden, Umschalten inkl. Persistieren, Sperre für belegte Plätze, verhinderte Zuweisung auf
ausgeblendete Plätze, `ShowSeat`/`CanToggleHidden` je nach Modus). 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 ### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/ - [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit, `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 Anhänge nach Anwenden eines `Documentation`-Ereignisses). Original-`StorageId` bleibt beim
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`, Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
das immer eine neue Id vergäbe). 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<DocumentAttachment> 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 - [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- 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 Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst") trägt jedes
+5
View File
@@ -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` (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). 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 ## Prompt Caching
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['unit'], $body['lesson'])) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
// Eigener Endpunkt (Nutzerwunsch neben 4.2 "Anhänge je Stunde"), da inhaltlich unabhängig von
// plan.php/explain.php: liefert einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser
// Stunde, zur Weiterbearbeitung im Gefährdungsbeurteilungs-Assistenten des Desktop-Clients.
$systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsvorbereitung einer Chemie-/Naturwissenschafts-Lehrkraft
an einer deutschen Schule. Du bekommst eine geplante Unterrichtsstunde ("lesson", mit ihrem
Verlaufsplan "phases") im Kontext ihrer Einheit ("unit"). Erkenne aus Thema, Tätigkeiten und
Material der Stunde, welches Experiment durchgeführt wird, und formuliere dafür EINEN Entwurf
einer schulüblichen Gefährdungsbeurteilung.
## WICHTIG — Grenzen dieser Aufgabe
Dies ist NUR ein Entwurf zur Weiterbearbeitung durch die Lehrkraft, KEIN rechtsverbindliches
Dokument — das macht die Lehrkraft der App bereits an dieser Stelle unmissverständlich klar,
wiederhole diesen Hinweis NICHT im JSON selbst. Gib ausschließlich Angaben wieder, die dir aus
allgemeinem Fachwissen zu den genannten Stoffen/Verfahren bekannt sind. Erfinde KEINE H-/P-Sätze
oder Mengenangaben, wenn du dir nicht sicher bist — schreibe in diesem Fall einen Platzhalter wie
"bitte gegen Sicherheitsdatenblatt prüfen" statt einer erfundenen Angabe. Sei bei der Einstufung
Lehrerversuch/Schülerversuch/Demonstrationsversuch eher vorsichtig (im Zweifel Lehrerversuch statt
Schülerversuch).
## Eingabeschema
{
"unit": { "title": "...", "subjectName": "...", "gradeLevel": <Zahl>, "groupName": "...", ...weitere Felder als Lesekontext },
"lesson": {
"topic": "<Thema>",
"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": "<Kurzbeschreibung der Durchführung, 2-4 Sätze>",
"substances": [
{
"name": "<Stoffname>",
"amount": "<typische Einsatzmenge, z.B. \"ca. 2 g\">",
"ghsPictograms": ["GHS05", "GHS02"],
"hStatements": "<H-Sätze mit Kurztext, kommagetrennt, z.B. \"H314 - Verursacht schwere Verätzungen\">",
"pStatements": "<P-Sätze, kommagetrennt>"
}
],
"hazards": ["<identifizierte Gefährdung 1>", "..."],
"protectiveMeasures": ["<Schutzmaßnahme 1>", "..."],
"firstAid": "<Erste-Hilfe-Hinweise>",
"disposal": "<Entsorgungshinweise>"
}
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);