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:
@@ -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; } = "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user