From 038337997fc3be9648ecc7df273c56d95078c4e6 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 20 Aug 2026 17:13:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Anh=C3=A4nge=20je=20Stunde,=20Klausur-S?= =?UTF-8?q?itzplan=20mischen,=20Gef=C3=A4hrdungsbeurteilungs-Assistent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LehrerApp.Core/AiPlanning/AiPlanningDtos.cs | 34 ++ LehrerApp.Core/Models/HazardAssessment.cs | 52 +++ LehrerApp.Core/Models/Planning.cs | 6 +- LehrerApp.Core/Models/Workload.cs | 13 +- LehrerApp.Data.Tests/RepositoryTests.cs | 19 + .../Repositories/AllRepositories.cs | 3 + .../HazardAssessmentWizardViewModelTests.cs | 283 +++++++++++++ .../LessonDialogViewModelTests.cs | 69 +++- .../PdfExportServiceTests.cs | 28 ++ .../SeatingPlanViewModelTests.cs | 65 +++ .../Services/AiPlanningService.cs | 41 ++ .../Services/PdfExportService.cs | 104 +++++ .../Groups/HazardAssessmentWizardViewModel.cs | 376 ++++++++++++++++++ .../ViewModels/Groups/PlanningViewModels.cs | 51 ++- .../Groups/SeatingPlanViewModels.cs | 35 ++ .../Students/DocumentationViewModels.cs | 6 + .../Groups/HazardAssessmentWizardDialog.axaml | 203 ++++++++++ .../HazardAssessmentWizardDialog.axaml.cs | 21 + .../Views/Groups/LessonDialog.axaml | 38 ++ .../Views/Groups/LessonDialog.axaml.cs | 133 ++++++- .../Views/Groups/PlanningTabView.axaml.cs | 1 + .../Views/Groups/SeatingPlanTabView.axaml | 3 + LehrerApp.Sync.Tests/EventApplierTests.cs | 26 ++ .../SyncEventPublisherTests.cs | 70 ++++ LehrerApp.Sync/EventApplier.cs | 21 +- LehrerApp.Sync/SyncEventPublisher.cs | 4 +- TODO.md | 108 +++++ ai-backend/README.md | 5 + ai-backend/gbu.php | 90 +++++ 29 files changed, 1886 insertions(+), 22 deletions(-) create mode 100644 LehrerApp.Core/Models/HazardAssessment.cs create mode 100644 LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml.cs create mode 100644 LehrerApp.Sync.Tests/SyncEventPublisherTests.cs create mode 100644 ai-backend/gbu.php diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index a9dca31..fade0f8 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -119,3 +119,37 @@ public class AiExplainResponse { public string Explanation { get; set; } = ""; } + +/// +/// Wire-Vertrag für den Gefährdungsbeurteilungs-Entwurf (ai-backend/gbu.php, Nutzerwunsch neben +/// 4.2 "Anhänge je Stunde"): erkennt aus Thema/Verlaufsplan der Lesson das Experiment und liefert +/// dafür einen strukturierten Entwurf. Eigener Endpunkt statt Zusatzfeld in plan.php/explain.php, +/// da inhaltlich unabhängig von der Unterrichtsplanung selbst. +/// +public class AiHazardAssessmentRequest +{ + public AiUnitContext Unit { get; set; } = new(); + public AiLesson Lesson { get; set; } = new(); +} + +public class AiHazardAssessmentResponse +{ + /// "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch" (siehe ExperimentKind). + public string ExperimentKind { get; set; } = ""; + public string Procedure { get; set; } = ""; + public List Substances { get; set; } = []; + public List Hazards { get; set; } = []; + public List ProtectiveMeasures { get; set; } = []; + public string FirstAid { get; set; } = ""; + public string Disposal { get; set; } = ""; +} + +public class AiHazardSubstance +{ + public string Name { get; set; } = ""; + public string Amount { get; set; } = ""; + /// GHS-Piktogramm-Codes als Text, z.B. "GHS05" — siehe GhsPictogram für die Zuordnung. + public List GhsPictograms { get; set; } = []; + public string HStatements { get; set; } = ""; + public string PStatements { get; set; } = ""; +} diff --git a/LehrerApp.Core/Models/HazardAssessment.cs b/LehrerApp.Core/Models/HazardAssessment.cs new file mode 100644 index 0000000..985d67a --- /dev/null +++ b/LehrerApp.Core/Models/HazardAssessment.cs @@ -0,0 +1,52 @@ +namespace LehrerApp.Core.Models; + +/// +/// Gefährdungsbeurteilung zu einem Experiment (fachspezifisch, v.a. Chemie/Naturwissenschaften). +/// Lebt NICHT als eigene LiteDB-Collection/Repository, sondern wird als JSON serialisiert und +/// über die bestehende -Infrastruktur an eine +/// angehängt (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2) — das reicht für Bearbeiten, +/// Anzeigen und PDF-Export, ohne eine weitere synchronisierte Entität samt Repository/DI-Wiring +/// einzuführen (bewusste Scope-Entscheidung, siehe TODO.md). +/// +public class HazardAssessment +{ + public string Title { get; set; } = ""; + /// Klassenstufe/Kurs, z.B. "10c" — vorbelegt aus der zugehörigen Lerngruppe, frei änderbar. + public string GroupLabel { get; set; } = ""; + public DateOnly? Date { get; set; } + public ExperimentKind Kind { get; set; } = ExperimentKind.Lehrerversuch; + /// Kurzbeschreibung der Durchführung. + public string Procedure { get; set; } = ""; + public List Substances { get; set; } = []; + public List Hazards { get; set; } = []; + public List ProtectiveMeasures { get; set; } = []; + public string FirstAid { get; set; } = ""; + public string Disposal { get; set; } = ""; + public string Notes { get; set; } = ""; + /// Ob ein KI-Entwurf beteiligt war — steuert den Rechtssicherheits-Hinweis im PDF-Export. + public bool IsAiAssisted { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class HazardSubstance +{ + public string Name { get; set; } = ""; + /// Typische Einsatzmenge, z.B. "ca. 2 g" — Freitext statt strukturierter Einheit, da der + /// Rahmen (fest/Lösung/Gas, Konzentration ...) zu unterschiedlich für ein starres Feld ist. + public string Amount { get; set; } = ""; + public List GhsPictograms { get; set; } = []; + /// H-Sätze (Gefahrenhinweise) als Freitext, z.B. "H314, H290" oder mit Kurztext — bewusst kein + /// eingebauter Katalog amtlicher H-/P-Satz-Texte in der App (Fehlerrisiko bei + /// sicherheitsrelevanten Angaben), die Lehrkraft prüft/ergänzt gegen das Sicherheitsdatenblatt. + public string HStatements { get; set; } = ""; + public string PStatements { get; set; } = ""; +} + +public enum ExperimentKind { Lehrerversuch, Schuelerversuch, Demonstrationsversuch } + +/// Die neun GHS-Gefahrenpiktogramme (Global Harmonisiertes System). +public enum GhsPictogram +{ + Explosive, Flammable, Oxidizing, CompressedGas, Corrosive, + Toxic, Harmful, HealthHazard, Environmental, +} diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index eef5d0d..6fcb6cc 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -43,7 +43,7 @@ public class Unit public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } -public class Lesson +public class Lesson : IHasAttachments { public Guid Id { get; set; } = Guid.NewGuid(); public Guid UnitId { get; set; } @@ -64,6 +64,10 @@ public class Lesson public bool HomeworkCheckDismissed { get; set; } public string? Reflection { get; set; } public LessonStatus Status { get; set; } = LessonStatus.Planned; + /// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und + /// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie + /// bei . + public List Attachments { get; set; } = []; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs index 5a41a93..a3e2af3 100644 --- a/LehrerApp.Core/Models/Workload.cs +++ b/LehrerApp.Core/Models/Workload.cs @@ -1,6 +1,6 @@ namespace LehrerApp.Core.Models; -public class Documentation +public class Documentation : IHasAttachments { public Guid Id { get; set; } = Guid.NewGuid(); public Guid StudentId { get; set; } @@ -77,6 +77,17 @@ public class DocumentAttachment public long SizeBytes { get; set; } public DateTime UploadedAt { get; set; } = DateTime.UtcNow; } +/// +/// Implementiert von jedem Modell mit Datei-Anhängen (aktuell und +/// ). Anhang-Bytes reisen nicht im JSON-Sync-Ereignis mit, sondern als eigener +/// Binärtransfer (siehe SyncEventPublisher/EventApplier/AttachmentSyncer in +/// LehrerApp.Sync) — dieses Interface macht die Erkennung "hat dieses Modell Anhänge?" dort +/// generisch statt hart auf einen einzelnen Modelltyp verdrahtet. +/// +public interface IHasAttachments +{ + List Attachments { get; } +} // Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben. public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter } public enum SupportStatus { Active, Completed, Paused } diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index a2eb7dd..94224c1 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -740,6 +740,25 @@ public sealed class RepositoryTests Assert.Equal(2, result[2].LessonNumber); } + [Fact] + public void LessonRepository_DeleteRaeumtAnhaengeAusDerAttachmentAblageAuf() + { + using var db = NewInMemoryContext(); + var repo = new LessonRepository(db); + var lesson = new Lesson + { + UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), + Attachments = [new DocumentAttachment { StorageId = "gbu-1", FileName = "gbu.pdf" }], + }; + db.Attachments.Upload("gbu-1", "gbu.pdf", new MemoryStream([1, 2, 3])); + repo.Save(lesson); + + repo.Delete(lesson.Id); + + Assert.Null(db.Lessons.FindById(lesson.Id)); + Assert.False(db.Attachments.Exists("gbu-1")); + } + // ── ShorthandCodeRepository ─────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 4223bae..6a53e09 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -389,7 +389,10 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository public void Delete(Guid id) { if (db.Lessons.FindById(id) is { } lesson) + { ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId); + foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId); + } db.Lessons.Delete(id); db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null); } diff --git a/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs b/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs new file mode 100644 index 0000000..afb1515 --- /dev/null +++ b/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs @@ -0,0 +1,283 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class HazardAssessmentWizardViewModelTests +{ + private static HazardAssessmentWizardViewModel Build(HazardAssessment? editing = null) => new(editing, "10c"); + + [Fact] + public void Neu_StartetAufSchritt0OhneKiOhneEditingKennzeichnung() + { + var vm = Build(); + + Assert.Equal(0, vm.StepIndex); + Assert.True(vm.IsBasicsStep); + Assert.True(vm.IsFirstStep); + Assert.Equal("Gefährdungsbeurteilung erstellen", vm.DialogTitle); + Assert.False(vm.CanUseAi); + } + + [Fact] + public void Next_OhneTitel_BleibtAufSchritt0UndSetztFehler() + { + var vm = Build(); + + vm.NextCommand.Execute(null); + + Assert.Equal(0, vm.StepIndex); + Assert.NotEqual("", vm.TitleError); + } + + [Fact] + public void Next_MitTitel_GehtEinenSchrittWeiterUndAktualisiertStepFlags() + { + var vm = Build(); + vm.Title = "Natrium in Wasser"; + + vm.NextCommand.Execute(null); + + Assert.Equal(1, vm.StepIndex); + Assert.True(vm.IsSubstancesStep); + Assert.False(vm.IsFirstStep); + Assert.Equal("", vm.TitleError); + } + + [Fact] + public void Back_AufSchritt0_BleibtAufSchritt0() + { + var vm = Build(); + + vm.BackCommand.Execute(null); + + Assert.Equal(0, vm.StepIndex); + } + + [Fact] + public void DurchlaufAllerSchritte_LandetAufZusammenfassung() + { + var vm = Build(); + vm.Title = "Natrium in Wasser"; + + for (var i = 0; i < vm.StepCount - 1; i++) vm.NextCommand.Execute(null); + + Assert.True(vm.IsSummaryStep); + Assert.True(vm.IsLastStep); + + vm.NextCommand.Execute(null); + Assert.True(vm.IsSummaryStep, "Weiter auf dem letzten Schritt darf nicht darüber hinausgehen."); + } + + [Fact] + public void AddSubstance_UndRemove_AktualisierenDieListeUndHasSubstances() + { + var vm = Build(); + Assert.False(vm.HasSubstances); + + vm.AddSubstanceCommand.Execute(null); + Assert.True(vm.HasSubstances); + var item = vm.Substances[0]; + item.Name = "Natrium"; + item.Amount = "ca. 0,5 g"; + item.PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true; + + item.RemoveCommand.Execute(null); + + Assert.False(vm.HasSubstances); + Assert.Empty(vm.Substances); + } + + [Fact] + public void AddHazard_TrimmtUndLeertDasEingabefeld() + { + var vm = Build(); + + vm.NewHazard = " Verätzungsgefahr "; + vm.AddHazardCommand.Execute(null); + + Assert.Equal(["Verätzungsgefahr"], vm.Hazards); + Assert.Equal("", vm.NewHazard); + } + + [Fact] + public void AddHazard_MitLeeremTextTutNichts() + { + var vm = Build(); + + vm.NewHazard = " "; + vm.AddHazardCommand.Execute(null); + + Assert.Empty(vm.Hazards); + } + + [Fact] + public void RemoveHazard_EntferntGenauDenGenanntenEintrag() + { + var vm = Build(); + vm.NewHazard = "A"; vm.AddHazardCommand.Execute(null); + vm.NewHazard = "B"; vm.AddHazardCommand.Execute(null); + + vm.RemoveHazardCommand.Execute("A"); + + Assert.Equal(["B"], vm.Hazards); + } + + [Fact] + public void AddProtectiveMeasure_TrimmtUndLeertDasEingabefeld() + { + var vm = Build(); + + vm.NewProtectiveMeasure = " Schutzbrille "; + vm.AddProtectiveMeasureCommand.Execute(null); + + Assert.Equal(["Schutzbrille"], vm.ProtectiveMeasures); + Assert.Equal("", vm.NewProtectiveMeasure); + } + + [Fact] + public void Save_OhneTitel_SetztFehlerUndSpringtZurueckAufSchritt0() + { + var vm = Build(); + vm.Title = "Etwas"; + vm.NextCommand.Execute(null); // Schritt 1 + vm.Title = " "; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.Equal(0, vm.StepIndex); + Assert.NotEqual("", vm.TitleError); + } + + [Fact] + public void Save_BautVollstaendigesErgebnisAusAllenSchritten() + { + var vm = Build(); + vm.Title = "Natrium in Wasser"; + vm.GroupLabel = "10c"; + vm.DateText = "12.09.2026"; + vm.KindName = "Lehrerversuch"; + vm.Procedure = "Kurzbeschreibung"; + vm.AddSubstanceCommand.Execute(null); + vm.Substances[0].Name = "Natrium"; + vm.Substances[0].Amount = "ca. 0,5 g"; + vm.Substances[0].HStatements = "H260"; + vm.Substances[0].PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true; + vm.NewHazard = "Verpuffung"; vm.AddHazardCommand.Execute(null); + vm.NewProtectiveMeasure = "Schutzbrille"; vm.AddProtectiveMeasureCommand.Execute(null); + vm.FirstAid = "Wasser spülen"; + vm.Disposal = "Abreagieren lassen"; + vm.Notes = "Hinweis"; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + var result = vm.Result!; + Assert.Equal("Natrium in Wasser", result.Title); + Assert.Equal("10c", result.GroupLabel); + Assert.Equal(new DateOnly(2026, 9, 12), result.Date); + Assert.Equal(ExperimentKind.Lehrerversuch, result.Kind); + var substance = Assert.Single(result.Substances); + Assert.Equal("Natrium", substance.Name); + Assert.Equal([GhsPictogram.Flammable], substance.GhsPictograms); + Assert.Equal(["Verpuffung"], result.Hazards); + Assert.Equal(["Schutzbrille"], result.ProtectiveMeasures); + Assert.Equal("Wasser spülen", result.FirstAid); + Assert.Equal("Abreagieren lassen", result.Disposal); + Assert.Equal("Hinweis", result.Notes); + Assert.False(result.IsAiAssisted); + } + + [Fact] + public void Konstruktor_MitEditingLaedtAlleFelderVor() + { + var editing = new HazardAssessment + { + Title = "Bestehende GBU", + GroupLabel = "9a", + Date = new DateOnly(2026, 1, 15), + Kind = ExperimentKind.Schuelerversuch, + Procedure = "Ablauf", + Substances = [new HazardSubstance { Name = "Salzsäure", GhsPictograms = [GhsPictogram.Corrosive] }], + Hazards = ["Verätzung"], + ProtectiveMeasures = ["Handschuhe"], + FirstAid = "Erste Hilfe Text", + Disposal = "Entsorgungstext", + Notes = "Notiz", + IsAiAssisted = true, + }; + + var vm = Build(editing); + + Assert.Equal("Gefährdungsbeurteilung bearbeiten", vm.DialogTitle); + Assert.Equal("Bestehende GBU", vm.Title); + Assert.Equal("9a", vm.GroupLabel); + Assert.Equal("15.01.2026", vm.DateText); + Assert.Equal("Schülerversuch", vm.KindName); + Assert.Equal("Ablauf", vm.Procedure); + var substance = Assert.Single(vm.Substances); + Assert.Equal("Salzsäure", substance.Name); + Assert.True(substance.PictogramOptions.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected); + Assert.Equal(["Verätzung"], vm.Hazards); + Assert.Equal(["Handschuhe"], vm.ProtectiveMeasures); + Assert.Equal("Erste Hilfe Text", vm.FirstAid); + Assert.Equal("Entsorgungstext", vm.Disposal); + Assert.Equal("Notiz", vm.Notes); + Assert.True(vm.IsAiAssisted); + } +} + +public sealed class ExperimentKindDisplayTests +{ + [Theory] + [InlineData(ExperimentKind.Lehrerversuch, "Lehrerversuch")] + [InlineData(ExperimentKind.Schuelerversuch, "Schülerversuch")] + [InlineData(ExperimentKind.Demonstrationsversuch, "Demonstrationsversuch")] + public void Label_UndFromLabel_SindZueinanderInvers(ExperimentKind kind, string label) + { + Assert.Equal(label, ExperimentKindDisplay.Label(kind)); + Assert.Equal(kind, ExperimentKindDisplay.FromLabel(label)); + } + + [Fact] + public void FromLabel_UnbekanntFaelltAufLehrerversuchZurueck() => + Assert.Equal(ExperimentKind.Lehrerversuch, ExperimentKindDisplay.FromLabel("Unbekannt")); + + [Theory] + [InlineData("Schuelerversuch", ExperimentKind.Schuelerversuch)] + [InlineData("Demonstrationsversuch", ExperimentKind.Demonstrationsversuch)] + [InlineData("etwas anderes", ExperimentKind.Lehrerversuch)] + public void FromWireValue_LiestDenUnmarkiertenEnumNamenDerKiAntwort(string wire, ExperimentKind expected) => + Assert.Equal(expected, ExperimentKindDisplay.FromWireValue(wire)); +} + +public sealed class GhsPictogramDisplayTests +{ + [Theory] + [InlineData(GhsPictogram.Explosive, "GHS01")] + [InlineData(GhsPictogram.Corrosive, "GHS05")] + [InlineData(GhsPictogram.Environmental, "GHS09")] + public void Code_LiefertDenErwartetenGhsCode(GhsPictogram value, string code) => + Assert.Equal(code, GhsPictogramDisplay.Code(value)); + + [Fact] + public void FromCode_IstFallUnabhaengigUndTrimmt() => + Assert.Equal(GhsPictogram.Corrosive, GhsPictogramDisplay.FromCode(" ghs05 ")); + + [Fact] + public void FromCode_UnbekannterCodeLiefertNull() => + Assert.Null(GhsPictogramDisplay.FromCode("GHS99")); + + [Fact] + public void BuildOptions_MarkiertNurDieUebergebenenAlsAusgewaehlt() + { + var options = GhsPictogramDisplay.BuildOptions([GhsPictogram.Toxic, GhsPictogram.Flammable]); + + Assert.Equal(9, options.Count); + Assert.True(options.Single(o => o.Value == GhsPictogram.Toxic).IsSelected); + Assert.True(options.Single(o => o.Value == GhsPictogram.Flammable).IsSelected); + Assert.False(options.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected); + } +} diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs index ac4591a..6cef547 100644 --- a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs @@ -1,3 +1,4 @@ +using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels.Groups; @@ -18,7 +19,7 @@ public sealed class LessonDialogViewModelTests private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null, FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) => new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), - slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), + slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], editing); /// Nutzer-Feedback: bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere @@ -92,7 +93,7 @@ public sealed class LessonDialogViewModelTests { var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]); var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]), - new FakeTimetableSlots(), NewPeriodSchedule(), + new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions); @@ -155,7 +156,7 @@ public sealed class LessonDialogViewModelTests var groupId = Guid.NewGuid(); var lessons = new FakeLessons(); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), - new FakeTimetableSlots(), NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null) + new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null) { Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45", }; @@ -179,6 +180,58 @@ public sealed class LessonDialogViewModelTests Assert.Single(lessons.GetByUnit(unitId)); } + [Fact] + public void AddAttachment_LaedtHochUndSaveUebernimmtIhnInDasErgebnis() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.Topic = "Thema"; vm.DateText = "01.09.2025"; + + vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3])); + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + var attachment = Assert.Single(vm.Result!.Attachments); + Assert.Equal("gbu-natrium.pdf", attachment.FileName); + Assert.Equal(3, attachment.SizeBytes); + } + + [Fact] + public void AddAttachment_ZuGrosseDateiWirdAbgelehnt() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + + vm.AddAttachment("riesig.pdf", new MemoryStream(new byte[IAttachmentStorage.MaxSizeBytes + 1])); + + Assert.Empty(vm.Attachments); + Assert.NotEqual("", vm.AttachmentError); + } + + [Fact] + public void RemoveAttachmentCommand_EntferntDenAnhangAusDerListe() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3])); + var item = vm.Attachments[0]; + + vm.RemoveAttachmentCommand.Execute(item); + + Assert.Empty(vm.Attachments); + } + + [Fact] + public void BearbeiteteStunde_LaedtVorhandeneAnhaengeVor() + { + var editing = new Lesson + { + Attachments = [new DocumentAttachment { StorageId = "abc", FileName = "gbu.pdf", SizeBytes = 42 }], + }; + + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing); + + var attachment = Assert.Single(vm.Attachments); + Assert.Equal("gbu.pdf", attachment.FileName); + } + [Fact] public void HasHomeworkText_OhneHausaufgabe_IstFalse() { @@ -305,7 +358,7 @@ public sealed class LessonDialogViewModelTests }; var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths, - new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing); + new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing); Assert.True(vm.Phases[0].HasAlternativePath); Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName); @@ -466,7 +519,7 @@ public sealed class LessonDialogViewModelTests slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), - slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null); + slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null); // Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099. Assert.Equal("13.01.2099", vm.DateText); @@ -485,7 +538,7 @@ public sealed class LessonDialogViewModelTests slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 }); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), - slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null); + slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null); var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy"); Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today)); @@ -506,7 +559,7 @@ public sealed class LessonDialogViewModelTests slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), - slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null); + slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null); Assert.Equal(3, vm.LessonNumber); // frühere Periode, nicht 4 } @@ -523,7 +576,7 @@ public sealed class LessonDialogViewModelTests periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 50), End = new TimeOnly(9, 35) }]); var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), - slots, periodSchedule, unitId, groupId, "10c", "Chemie", [], [], null); + slots, periodSchedule, new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null); Assert.Equal(2, vm.LessonNumber); Assert.Equal("08:50", vm.StartTimeText); diff --git a/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs b/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs index ce1e68b..e860fb5 100644 --- a/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs +++ b/LehrerApp.Desktop.Tests/PdfExportServiceTests.cs @@ -154,4 +154,32 @@ public sealed class PdfExportServiceTests AssertIsPdf(Service.BuildStudentDocumentationPdf(data)); } + + [Fact] + public void BuildHazardAssessmentPdf_MitStoffenUndKiHinweis_LiefertGueltigesPdf() + { + var data = new HazardAssessmentPrintData( + "Natrium in Wasser", "10c", "12.09.2026", "Lehrerversuch", + "Ein kleines Stück Natrium wird in eine Schale mit Wasser gegeben.", + [ + new HazardSubstancePrintRow("Natrium", "ca. 0,5 g", "GHS02, GHS05", + "H260 - Reagiert mit Wasser unter Bildung entzündbarer Gase", "P223, P231+P232, P370+P378"), + ], + ["Wasserstoffbildung, Verpuffungsgefahr", "Verätzungsgefahr durch Natronlauge"], + ["Schutzbrille", "Sicherheitsabstand einhalten", "Abzug/Freiluftversuch"], + "Betroffene Stellen mit viel Wasser spülen, Arzt hinzuziehen.", + "Reste unter Aufsicht vollständig abreagieren lassen.", + "", IsAiAssisted: true); + + AssertIsPdf(Service.BuildHazardAssessmentPdf(data)); + } + + [Fact] + public void BuildHazardAssessmentPdf_OhneStoffeUndOhneKiHinweis_LiefertGueltigesPdf() + { + var data = new HazardAssessmentPrintData( + "Papierchromatographie", "8b", "", "Schülerversuch", "", [], [], [], "", "", "", IsAiAssisted: false); + + AssertIsPdf(Service.BuildHazardAssessmentPdf(data)); + } } diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs index 5c9c11a..ea67586 100644 --- a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs @@ -542,4 +542,69 @@ public sealed class SeatingPlanViewModelTests Assert.Equal("Sitzplan", session.Comment); Assert.Equal(session.Id, vm.SelectedSession?.Id); } + + [Fact] + public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan() + { + var groupId = Guid.NewGuid(); + var anna = new Student { FirstName = "Anna", LastName = "A" }; + var ben = new Student { FirstName = "Ben", LastName = "B" }; + var cara = new Student { FirstName = "Cara", LastName = "C" }; + var students = new FakeStudents([anna, ben, cara]); + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = groupId, StudentId = anna.Id }, + new GroupMembership { GroupId = groupId, StudentId = ben.Id }, + new GroupMembership { GroupId = groupId, StudentId = cara.Id }, + ]); + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Klausur", Room = "B204", Rows = 1, Columns = 3, + Assignments = + [ + new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id }, + new SeatAssignment { Row = 0, Column = 1, StudentId = ben.Id }, + new SeatAssignment { Row = 0, Column = 2, StudentId = cara.Id }, + ], + }; + var plans = new FakeSeatingPlans([plan]); + var vm = new SeatingPlanTabViewModel(plans, students, memberships, + new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + vm.IsEditMode = true; + + vm.ShuffleSeatsCommand.Execute(null); + + var all = plans.GetByGroup(groupId); + Assert.Equal(2, all.Count); + var shuffled = Assert.Single(all, p => p.Id != plan.Id); + Assert.Equal("B204", shuffled.Room); + Assert.Equal(1, shuffled.Rows); + Assert.Equal(3, shuffled.Columns); + // Dieselben drei Schüler auf denselben drei Koordinaten, nur die Zuordnung untereinander + // wurde neu gewürfelt. + Assert.Equal(new[] { anna.Id, ben.Id, cara.Id }.OrderBy(id => id), + shuffled.Assignments.Select(a => a.StudentId).OrderBy(id => id)); + Assert.Equal(new[] { (0, 0), (0, 1), (0, 2) }, + shuffled.Assignments.Select(a => (a.Row, a.Column)).OrderBy(t => t.Item2)); + // Ursprungsplan bleibt unverändert erhalten. + Assert.Equal(3, plans.GetById(plan.Id)!.Assignments.Count); + Assert.Equal(vm.SelectedPlan?.Id, shuffled.Id); + } + + [Fact] + public void ShuffleSeatsCommand_OhneBearbeitungsmodus_NichtAusfuehrbar() + { + var groupId = Guid.NewGuid(); + var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 }; + var vm = new SeatingPlanTabViewModel( + new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]), + new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + + Assert.False(vm.ShuffleSeatsCommand.CanExecute(null)); + + vm.IsEditMode = true; + + Assert.True(vm.ShuffleSeatsCommand.CanExecute(null)); + } } diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 58182c4..abb331b 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -381,6 +381,47 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, } } + /// + /// Fragt einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser Lesson ab (Nutzerwunsch + /// neben 4.2 "Anhänge je Stunde"): die KI erkennt aus Thema/Verlaufsplan das Experiment und + /// liefert Stoffe, Gefährdungen, Schutzmaßnahmen etc. als Entwurf zur Weiterbearbeitung im + /// Gefährdungsbeurteilungs-Assistenten. Ändert nichts an der Lesson selbst. + /// + public async Task RequestHazardAssessmentDraftAsync(Unit unit, AiLesson lesson, string token) + { + var request = new AiHazardAssessmentRequest { Unit = BuildContext(unit, ""), Lesson = lesson }; + + using var req = new HttpRequestMessage(HttpMethod.Post, "gbu.php") + { + Content = JsonContent.Create(request, options: JsonOptions), + }; + req.Headers.Authorization = new("Bearer", token); + + HttpResponseMessage resp; + try { resp = await http.SendAsync(req); } + catch (HttpRequestException) + { + throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen."); + } + + if (resp.StatusCode == HttpStatusCode.Unauthorized) + throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden."); + if (resp.StatusCode == (HttpStatusCode)402) + throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen."); + if (!resp.IsSuccessStatusCode) + throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen."); + + try + { + var result = await resp.Content.ReadFromJsonAsync(JsonOptions); + return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden."); + } + catch (Exception ex) when (ex is not AiBackendException) + { + throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen."); + } + } + /// /// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar /// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft diff --git a/LehrerApp.Desktop/Services/PdfExportService.cs b/LehrerApp.Desktop/Services/PdfExportService.cs index 3171982..81e6ce0 100644 --- a/LehrerApp.Desktop/Services/PdfExportService.cs +++ b/LehrerApp.Desktop/Services/PdfExportService.cs @@ -73,6 +73,23 @@ public sealed record DocumentationPrintEntry( bool IsConfidential, bool IsDraft); +public sealed record HazardAssessmentPrintData( + string Title, + string GroupLabel, + string DateDisplay, + string KindLabel, + string Procedure, + IReadOnlyList Substances, + IReadOnlyList Hazards, + IReadOnlyList ProtectiveMeasures, + string FirstAid, + string Disposal, + string Notes, + bool IsAiAssisted); + +public sealed record HazardSubstancePrintRow( + string Name, string Amount, string Pictograms, string HStatements, string PStatements); + /// /// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei /// allen Exporten über . Lebt bewusst in LehrerApp.Desktop statt Core: @@ -346,8 +363,95 @@ public sealed class PdfExportService page.Footer().Element(Footer); })).GeneratePdf(); + public byte[] BuildHazardAssessmentPdf(HazardAssessmentPrintData data) => + Document.Create(container => container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(36); + page.DefaultTextStyle(style => style.FontSize(9)); + + page.Header().Element(header => Header(header, $"Gefährdungsbeurteilung · {data.Title}", + $"{data.GroupLabel} · {data.KindLabel} · {data.DateDisplay}")); + + page.Content().PaddingVertical(10).Column(column => + { + column.Spacing(10); + if (data.IsAiAssisted) column.Item().Element(AiDisclaimerBanner); + if (!string.IsNullOrWhiteSpace(data.Procedure)) + column.Item().Element(c => TextSection(c, "Durchführung", data.Procedure)); + if (data.Substances.Count > 0) + column.Item().Element(c => SubstanceTable(c, data.Substances)); + if (data.Hazards.Count > 0) + column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards)); + if (data.ProtectiveMeasures.Count > 0) + column.Item().Element(c => BulletSection(c, "Schutzmaßnahmen", data.ProtectiveMeasures)); + if (!string.IsNullOrWhiteSpace(data.FirstAid)) + column.Item().Element(c => TextSection(c, "Erste Hilfe", data.FirstAid)); + if (!string.IsNullOrWhiteSpace(data.Disposal)) + column.Item().Element(c => TextSection(c, "Entsorgung", data.Disposal)); + if (!string.IsNullOrWhiteSpace(data.Notes)) + column.Item().Element(c => TextSection(c, "Weitere Hinweise", data.Notes)); + }); + + page.Footer().Element(Footer); + })).GeneratePdf(); + // ── Bausteine ─────────────────────────────────────────────────────────────── + private static void AiDisclaimerBanner(IContainer container) => + container.Background(Colors.Orange.Lighten4).Padding(8).Text( + "⚠️ Enthält KI-generierte Anteile — kein rechtssicheres Dokument. Vor Verwendung " + + "eigenverantwortlich prüfen, insbesondere H-/P-Sätze und Mengenangaben gegen das " + + "Sicherheitsdatenblatt.").FontSize(9).FontColor(Colors.Orange.Darken3).SemiBold(); + + private static void TextSection(IContainer container, string title, string text) => + container.Column(column => + { + column.Item().Text(title).FontSize(11).SemiBold(); + column.Item().Text(text); + }); + + private static void BulletSection(IContainer container, string title, IReadOnlyList items) => + container.Column(column => + { + column.Item().Text(title).FontSize(11).SemiBold(); + foreach (var item in items) + column.Item().Text($"• {item}"); + }); + + private static void SubstanceTable(IContainer container, IReadOnlyList rows) => + container.Column(column => + { + column.Item().Text("Gefahrstoffe").FontSize(11).SemiBold(); + column.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(2f); + columns.RelativeColumn(1f); + columns.RelativeColumn(1.3f); + columns.RelativeColumn(2f); + columns.RelativeColumn(2f); + }); + table.Header(header => + { + header.Cell().Element(HeaderCell).Text("Stoff").SemiBold(); + header.Cell().Element(HeaderCell).Text("Menge").SemiBold(); + header.Cell().Element(HeaderCell).Text("GHS").SemiBold(); + header.Cell().Element(HeaderCell).Text("H-Sätze").SemiBold(); + header.Cell().Element(HeaderCell).Text("P-Sätze").SemiBold(); + }); + foreach (var row in rows) + { + table.Cell().Element(BodyCell).Text(row.Name); + table.Cell().Element(BodyCell).Text(row.Amount); + table.Cell().Element(BodyCell).Text(row.Pictograms); + table.Cell().Element(BodyCell).Text(row.HStatements); + table.Cell().Element(BodyCell).Text(row.PStatements); + } + }); + }); + /// Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt). private static void Bar(IContainer container, double percent, string color) => container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3) diff --git a/LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs new file mode 100644 index 0000000..b00c11b --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs @@ -0,0 +1,376 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.AiPlanning; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Anzeige: Experimentart, GHS-Piktogramme (Nutzerwunsch neben 4.2 "Anhänge je Stunde") ──────── + +public static class ExperimentKindDisplay +{ + public static string[] Options { get; } = ["Lehrerversuch", "Schülerversuch", "Demonstrationsversuch"]; + + public static string Label(ExperimentKind kind) => kind switch + { + ExperimentKind.Schuelerversuch => "Schülerversuch", + ExperimentKind.Demonstrationsversuch => "Demonstrationsversuch", + _ => "Lehrerversuch", + }; + + public static ExperimentKind FromLabel(string? label) => label switch + { + "Schülerversuch" => ExperimentKind.Schuelerversuch, + "Demonstrationsversuch" => ExperimentKind.Demonstrationsversuch, + _ => ExperimentKind.Lehrerversuch, + }; + + /// Für die KI-Antwort, die den unmarkierten Enum-Namen ("Schuelerversuch") statt der + /// deutschen Anzeige-Bezeichnung liefert (siehe ai-backend/gbu.php "Antwortformat"). + public static ExperimentKind FromWireValue(string? value) => + Enum.TryParse(value, ignoreCase: true, out var kind) ? kind : ExperimentKind.Lehrerversuch; +} + +public static class GhsPictogramDisplay +{ + private static readonly (GhsPictogram Value, string Code, string Label)[] All = + [ + (GhsPictogram.Explosive, "GHS01", "Explosionsgefährlich"), + (GhsPictogram.Flammable, "GHS02", "Entzündbar"), + (GhsPictogram.Oxidizing, "GHS03", "Brandfördernd"), + (GhsPictogram.CompressedGas, "GHS04", "Gase unter Druck"), + (GhsPictogram.Corrosive, "GHS05", "Ätzend"), + (GhsPictogram.Toxic, "GHS06", "Giftig"), + (GhsPictogram.Harmful, "GHS07", "Gesundheitsschädlich/Reizend"), + (GhsPictogram.HealthHazard, "GHS08", "Gesundheitsgefährdend"), + (GhsPictogram.Environmental, "GHS09", "Umweltgefährdend"), + ]; + + public static string Code(GhsPictogram value) => All.First(o => o.Value == value).Code; + public static string Label(GhsPictogram value) => All.First(o => o.Value == value).Label; + + public static GhsPictogram? FromCode(string? code) + { + if (string.IsNullOrWhiteSpace(code)) return null; + foreach (var option in All) + if (string.Equals(option.Code, code.Trim(), StringComparison.OrdinalIgnoreCase)) + return option.Value; + return null; + } + + public static List BuildOptions(IEnumerable? selected = null) + { + var selectedSet = (selected ?? []).ToHashSet(); + return All.Select(o => new GhsPictogramOption(o.Value, $"{o.Code} · {o.Label}") { IsSelected = selectedSet.Contains(o.Value) }).ToList(); + } +} + +public partial class GhsPictogramOption(GhsPictogram value, string label) : ObservableObject +{ + public GhsPictogram Value { get; } = value; + public string Label { get; } = label; + [ObservableProperty] private bool _isSelected; +} + +// ── Zeile im Gefahrstoff-Editor (Wizard-Schritt 2) ─────────────────────────────────────────────── + +public partial class HazardSubstanceEditItem : ObservableObject +{ + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _amount = ""; + [ObservableProperty] private string _hStatements = ""; + [ObservableProperty] private string _pStatements = ""; + public ObservableCollection PictogramOptions { get; } + public Action? OnRemove { get; set; } + + public HazardSubstanceEditItem(HazardSubstance? source = null) + { + PictogramOptions = new ObservableCollection( + GhsPictogramDisplay.BuildOptions(source?.GhsPictograms)); + if (source is null) return; + Name = source.Name; + Amount = source.Amount; + HStatements = source.HStatements; + PStatements = source.PStatements; + } + + [RelayCommand] + private void Remove() => OnRemove?.Invoke(this); + + public HazardSubstance ToModel() => new() + { + Name = Name.Trim(), + Amount = Amount.Trim(), + HStatements = HStatements.Trim(), + PStatements = PStatements.Trim(), + GhsPictograms = PictogramOptions.Where(o => o.IsSelected).Select(o => o.Value).ToList(), + }; +} + +// ── Wizard: Gefährdungsbeurteilung anlegen/bearbeiten ──────────────────────────────────────────── + +/// +/// Mehrschrittiger Assistent für eine Gefährdungsbeurteilung zu einem Experiment (Nutzerwunsch +/// neben 4.2 "Anhänge je Stunde"). Persistiert NICHT selbst — der Aufrufer (LessonDialog) +/// serialisiert zu JSON und hängt es über die bestehende +/// Anhang-Infrastruktur an die Lesson an (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2). +/// KI-Unterstützung ist optional: ohne // +/// bleibt false und der Assistent funktioniert +/// rein manuell. +/// +public partial class HazardAssessmentWizardViewModel : ObservableObject +{ + private static readonly string[] StepTitlesArray = + ["Basisdaten", "Gefahrstoffe", "Gefährdungen & Schutzmaßnahmen", "Erste Hilfe & Entsorgung", "Zusammenfassung"]; + + private readonly AiPlanningService? _ai; + private readonly AiSettingsService? _aiSettings; + private readonly Unit? _unit; + private readonly Lesson? _lesson; + + [ObservableProperty] private int _stepIndex; + [ObservableProperty] private string _title = ""; + [ObservableProperty] private string _titleError = ""; + [ObservableProperty] private string _groupLabel = ""; + [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); + [ObservableProperty] private string _kindName = ExperimentKindDisplay.Options[0]; + [ObservableProperty] private string _procedure = ""; + + public ObservableCollection Substances { get; } = []; + public bool HasSubstances => Substances.Count > 0; + + [ObservableProperty] private string _newHazard = ""; + public ObservableCollection Hazards { get; } = []; + [ObservableProperty] private string _newProtectiveMeasure = ""; + public ObservableCollection ProtectiveMeasures { get; } = []; + + [ObservableProperty] private string _firstAid = ""; + [ObservableProperty] private string _disposal = ""; + [ObservableProperty] private string _notes = ""; + + [ObservableProperty] private bool _isAiAssisted; + [ObservableProperty] private bool _isAiBusy; + [ObservableProperty] private string _aiError = ""; + public string AiButtonLabel => IsAiBusy ? "KI fragt..." : "🤖 KI-Entwurf erstellen"; + + public bool CanUseAi => _ai is not null && _aiSettings is { Enabled: true, IsLoggedIn: true } + && _unit is not null && _lesson is not null; + + public string[] StepTitles => StepTitlesArray; + public string StepTitle => StepTitles[StepIndex]; + public string StepProgressLabel => $"Schritt {StepIndex + 1} von {StepTitles.Length}"; + public int StepCount => StepTitles.Length; + public bool IsFirstStep => StepIndex == 0; + public bool IsLastStep => StepIndex == StepTitles.Length - 1; + public bool IsBasicsStep => StepIndex == 0; + public bool IsSubstancesStep => StepIndex == 1; + public bool IsHazardsStep => StepIndex == 2; + public bool IsFirstAidStep => StepIndex == 3; + public bool IsSummaryStep => StepIndex == 4; + public string[] KindOptions => ExperimentKindDisplay.Options; + public string DialogTitle => _editing ? "Gefährdungsbeurteilung bearbeiten" : "Gefährdungsbeurteilung erstellen"; + + private readonly bool _editing; + + public HazardAssessment? Result { get; private set; } + + public HazardAssessmentWizardViewModel(HazardAssessment? editing, string defaultGroupLabel, + AiPlanningService? ai = null, AiSettingsService? aiSettings = null, Unit? unit = null, Lesson? lesson = null) + { + _ai = ai; _aiSettings = aiSettings; _unit = unit; _lesson = lesson; + Substances.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasSubstances)); + GroupLabel = defaultGroupLabel; + _editing = editing is not null; + if (editing is null) return; + + Title = editing.Title; + GroupLabel = editing.GroupLabel; + DateText = editing.Date?.ToString("dd.MM.yyyy") ?? ""; + KindName = ExperimentKindDisplay.Label(editing.Kind); + Procedure = editing.Procedure; + foreach (var s in editing.Substances) Substances.Add(NewSubstanceItem(s)); + foreach (var h in editing.Hazards) Hazards.Add(h); + foreach (var m in editing.ProtectiveMeasures) ProtectiveMeasures.Add(m); + FirstAid = editing.FirstAid; + Disposal = editing.Disposal; + Notes = editing.Notes; + IsAiAssisted = editing.IsAiAssisted; + } + + partial void OnIsAiBusyChanged(bool value) => OnPropertyChanged(nameof(AiButtonLabel)); + + partial void OnStepIndexChanged(int value) + { + OnPropertyChanged(nameof(StepTitle)); + OnPropertyChanged(nameof(StepProgressLabel)); + OnPropertyChanged(nameof(IsFirstStep)); + OnPropertyChanged(nameof(IsLastStep)); + OnPropertyChanged(nameof(IsBasicsStep)); + OnPropertyChanged(nameof(IsSubstancesStep)); + OnPropertyChanged(nameof(IsHazardsStep)); + OnPropertyChanged(nameof(IsFirstAidStep)); + OnPropertyChanged(nameof(IsSummaryStep)); + } + + private HazardSubstanceEditItem NewSubstanceItem(HazardSubstance? source = null) + { + var item = new HazardSubstanceEditItem(source) { OnRemove = RemoveSubstance }; + return item; + } + + [RelayCommand] + private void AddSubstance() => Substances.Add(NewSubstanceItem()); + + private void RemoveSubstance(HazardSubstanceEditItem item) => Substances.Remove(item); + + [RelayCommand] + private void AddHazard() + { + if (string.IsNullOrWhiteSpace(NewHazard)) return; + Hazards.Add(NewHazard.Trim()); + NewHazard = ""; + } + + [RelayCommand] + private void RemoveHazard(string? hazard) { if (hazard is not null) Hazards.Remove(hazard); } + + [RelayCommand] + private void AddProtectiveMeasure() + { + if (string.IsNullOrWhiteSpace(NewProtectiveMeasure)) return; + ProtectiveMeasures.Add(NewProtectiveMeasure.Trim()); + NewProtectiveMeasure = ""; + } + + [RelayCommand] + private void RemoveProtectiveMeasure(string? measure) { if (measure is not null) ProtectiveMeasures.Remove(measure); } + + [RelayCommand] + private void Next() + { + if (IsFirstStep && string.IsNullOrWhiteSpace(Title)) + { + TitleError = "Titel erforderlich."; + return; + } + TitleError = ""; + if (!IsLastStep) StepIndex++; + } + + [RelayCommand] + private void Back() { if (!IsFirstStep) StepIndex--; } + + /// + /// Fragt einen KI-Entwurf zu genau dieser Lesson ab (Thema + Verlaufsplan als Kontext) und + /// übernimmt ihn in die Wizard-Felder — überschreibt dabei bewusst den aktuellen Stand, damit + /// nach einem erneuten Klick klar ist, was tatsächlich von der KI kommt. Die Lehrkraft prüft + /// und passt danach in den einzelnen Schritten an; der Rechtssicherheits-Hinweis wird über + /// in den PDF-Export übernommen. + /// + [RelayCommand] + private async Task RequestAiDraft() + { + if (!CanUseAi) return; + var token = _aiSettings!.GetToken(); + if (token is null) + { + AiError = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden."; + return; + } + + AiError = ""; IsAiBusy = true; + try + { + var aiLesson = new AiLesson + { + Id = _lesson!.Id, + Date = _lesson.Date, + LessonNumber = _lesson.LessonNumber, + Topic = _lesson.Topic, + StartTime = _lesson.StartTime, + Phases = _lesson.Phases.Select(p => new AiPhaseStep + { + Name = p.Name, DurationMinutes = p.DurationMinutes, + Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, + }).ToList(), + }; + + var response = await _ai!.RequestHazardAssessmentDraftAsync(_unit!, aiLesson, token); + ApplyAiResponse(response); + } + catch (AiBackendException ex) + { + AiError = ex.Message; + } + finally + { + IsAiBusy = false; + } + } + + private void ApplyAiResponse(AiHazardAssessmentResponse response) + { + if (string.IsNullOrWhiteSpace(Title)) Title = _lesson?.Topic ?? ""; + KindName = ExperimentKindDisplay.Label(ExperimentKindDisplay.FromWireValue(response.ExperimentKind)); + Procedure = response.Procedure; + + Substances.Clear(); + foreach (var s in response.Substances) + Substances.Add(NewSubstanceItem(new HazardSubstance + { + Name = s.Name, + Amount = s.Amount, + HStatements = s.HStatements, + PStatements = s.PStatements, + GhsPictograms = s.GhsPictograms + .Select(GhsPictogramDisplay.FromCode) + .Where(p => p.HasValue) + .Select(p => p!.Value) + .ToList(), + })); + + Hazards.Clear(); + foreach (var h in response.Hazards) Hazards.Add(h); + ProtectiveMeasures.Clear(); + foreach (var m in response.ProtectiveMeasures) ProtectiveMeasures.Add(m); + FirstAid = response.FirstAid; + Disposal = response.Disposal; + IsAiAssisted = true; + } + + [RelayCommand] + private void Save() + { + if (string.IsNullOrWhiteSpace(Title)) + { + TitleError = "Titel erforderlich."; + StepIndex = 0; + return; + } + TitleError = ""; + + DateOnly? date = null; + if (!string.IsNullOrWhiteSpace(DateText) && + DateOnly.TryParseExact(DateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + date = parsed; + + Result = new HazardAssessment + { + Title = Title.Trim(), + GroupLabel = GroupLabel.Trim(), + Date = date, + Kind = ExperimentKindDisplay.FromLabel(KindName), + Procedure = Procedure.Trim(), + Substances = Substances.Select(s => s.ToModel()).ToList(), + Hazards = Hazards.ToList(), + ProtectiveMeasures = ProtectiveMeasures.ToList(), + FirstAid = FirstAid.Trim(), + Disposal = Disposal.Trim(), + Notes = Notes.Trim(), + IsAiAssisted = IsAiAssisted, + }; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 04e0030..2ce7ec7 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Students; using System.Collections.ObjectModel; using System.Globalization; @@ -655,9 +656,11 @@ public partial class LessonDialogViewModel : ObservableObject private readonly IAlternativeLessonPathRepository _alternativePaths; private readonly ITimetableSlotRepository _timetableSlots; private readonly PeriodScheduleService _periodSchedule; + private readonly IAttachmentStorage _attachmentStorage; private readonly Guid _unitId; private readonly Guid _groupId; private readonly Lesson? _editingLesson; + private readonly List _newlyUploadedStorageIds = []; [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private int? _lessonNumber; @@ -684,6 +687,10 @@ public partial class LessonDialogViewModel : ObservableObject public string[] ShorthandSuggestions { get; } public ObservableCollection Phases { get; } = []; + // Anhänge (Material/Arbeitsblätter, Experiment-/Gefährdungsbeurteilungsdokumente). + public ObservableCollection Attachments { get; } = []; + [ObservableProperty] private string _attachmentError = ""; + /// Vom Code-Behind gesetzt (Fenster als Owner für den Zuweisen-Dialog): fragt nach dem /// alternativen Ablauf, dem eine Phase zugeordnet werden soll (Auswahl oder Neuanlage /// per Combobox-Dialog). null zurückgegeben = abgebrochen. @@ -713,11 +720,13 @@ public partial class LessonDialogViewModel : ObservableObject public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes, IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots, - PeriodScheduleService periodSchedule, Guid unitId, Guid groupId, string groupName, string subjectName, + PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage, + Guid unitId, Guid groupId, string groupName, string subjectName, List materialSuggestions, List shorthandHistorySuggestions, Lesson? editingLesson) { _lessons = lessons; _alternativePaths = alternativePaths; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; + _attachmentStorage = attachmentStorage; _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson; GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName) ? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}"; @@ -746,6 +755,8 @@ public partial class LessonDialogViewModel : ObservableObject Reflection = editingLesson.Reflection ?? ""; StatusName = LessonStatusDisplay.ToName(editingLesson.Status); foreach (var p in editingLesson.Phases) AddPhaseInternal(p); + foreach (var att in editingLesson.Attachments) + Attachments.Add(new AttachmentItem(att.StorageId, att.FileName, att.SizeBytes, att.UploadedAt)); } else { @@ -925,6 +936,39 @@ public partial class LessonDialogViewModel : ObservableObject _ => "#B71C1C", // Dunkelrot: deutlich überplant }; + // ── Anhänge ──────────────────────────────────────────────────────────── + + public void AddAttachment(string fileName, Stream content) + { + AttachmentError = ""; + if (content.Length > IAttachmentStorage.MaxSizeBytes) + { + AttachmentError = $"Datei zu groß (max. {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB)."; + return; + } + var storageId = _attachmentStorage.Upload(fileName, content); + _newlyUploadedStorageIds.Add(storageId); + Attachments.Add(new AttachmentItem(storageId, fileName, content.Length, DateTime.UtcNow)); + } + + public Stream? OpenAttachment(AttachmentItem item) => _attachmentStorage.OpenRead(item.StorageId); + + [RelayCommand] + private void RemoveAttachment(AttachmentItem? item) + { + if (item is null) return; + _attachmentStorage.Delete(item.StorageId); + _newlyUploadedStorageIds.Remove(item.StorageId); + Attachments.Remove(item); + } + + /// Vom Code-Behind beim Abbrechen aufgerufen: neu hochgeladene, nie gespeicherte Anhänge + /// wieder entfernen, damit keine verwaisten Blobs in der Datenbank zurückbleiben. + public void DiscardUnsavedAttachments() + { + foreach (var id in _newlyUploadedStorageIds) _attachmentStorage.Delete(id); + } + [RelayCommand] private void Save() { @@ -960,7 +1004,12 @@ public partial class LessonDialogViewModel : ObservableObject Result.HomeworkCheckDismissed = HomeworkCheckDismissed; Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim(); Result.Status = LessonStatusDisplay.FromName(StatusName); + Result.Attachments = Attachments.Select(a => new DocumentAttachment + { + StorageId = a.StorageId, FileName = a.FileName, SizeBytes = a.SizeBytes, UploadedAt = a.UploadedAt, + }).ToList(); _lessons.Save(Result); + _newlyUploadedStorageIds.Clear(); } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index 7d7652c..1174670 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -398,6 +398,40 @@ public partial class SeatingPlanTabViewModel : ObservableObject ReloadPlans(); } + /// + /// Klausur-Sitzplan mischen (14, Nutzer-Idee): erzeugt aus dem aktuell gewählten Plan einen + /// neuen, unabhängigen Plan mit gleichem Raster/Raum, aber zufällig vertauschten Insassen der + /// belegten Plätze (Fisher-Yates via ) — die Platzkoordinaten selbst + /// bleiben unverändert, nur wer wo sitzt wird neu gewürfelt. Der ursprüngliche Plan bleibt + /// unangetastet erhalten, damit er bei Bedarf für den nächsten regulären Unterricht weiter + /// genutzt werden kann. + /// + [RelayCommand(CanExecute = nameof(CanEditSelected))] + private void ShuffleSeats() + { + if (_currentPlan is null) return; + + var studentIds = _currentPlan.Assignments.Select(a => a.StudentId).ToArray(); + Random.Shared.Shuffle(studentIds); + + var shuffled = new SeatingPlan + { + GroupId = _currentPlan.GroupId, + Name = $"{_currentPlan.Name} (Klausur gemischt {DateTime.Now:dd.MM. HH:mm})", + Room = _currentPlan.Room, + Rows = _currentPlan.Rows, + Columns = _currentPlan.Columns, + ColumnGapWidths = [.. _currentPlan.ColumnGapWidths], + IsBoardAtBottom = _currentPlan.IsBoardAtBottom, + HiddenSeats = _currentPlan.HiddenSeats.Select(h => new HiddenSeat { Row = h.Row, Column = h.Column }).ToList(), + Assignments = _currentPlan.Assignments + .Select((a, i) => new SeatAssignment { Row = a.Row, Column = a.Column, StudentId = studentIds[i] }) + .ToList(), + }; + _plans.Save(shuffled); + ReloadPlans(shuffled.Id); + } + partial void OnIsEditModeChanged(bool value) { OnPropertyChanged(nameof(CanEditLayout)); @@ -417,6 +451,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject AddPlanCommand.NotifyCanExecuteChanged(); EditPlanCommand.NotifyCanExecuteChanged(); DeletePlanCommand.NotifyCanExecuteChanged(); + ShuffleSeatsCommand.NotifyCanExecuteChanged(); } } diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs index e1b15ae..791f58d 100644 --- a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs @@ -401,11 +401,17 @@ public partial class DocumentationDialogViewModel : ObservableObject public class AttachmentItem(string storageId, string fileName, long sizeBytes, DateTime uploadedAt) { + public const string HazardAssessmentSuffix = ".gbu.json"; + public string StorageId { get; } = storageId; public string FileName { get; } = fileName; public long SizeBytes { get; } = sizeBytes; public DateTime UploadedAt { get; } = uploadedAt; public string SizeDisplay => $"{SizeBytes / 1024.0:0} KB"; + /// Strukturierter Gefährdungsbeurteilungs-Anhang (siehe HazardAssessment) statt einer + /// beliebigen hochgeladenen Datei — steuert im Anhang-Editor, ob "Öffnen"+"PDF" statt des + /// generischen "Speichern" angeboten wird. + public bool IsHazardAssessment => FileName.EndsWith(HazardAssessmentSuffix, StringComparison.OrdinalIgnoreCase); } public class TagChip(string text) diff --git a/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml b/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml new file mode 100644 index 0000000..d36742f --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + +