using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.AiPlanning; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Groups; // ── Anzeige: Experimentart, GHS-Piktogramme (Nutzerwunsch neben 4.2 "Anhänge je Stunde") ──────── public static class ExperimentKindDisplay { public static string[] Options { get; } = ["Lehrerversuch", "Schülerversuch", "Demonstrationsversuch"]; public static string Label(ExperimentKind kind) => kind switch { ExperimentKind.Schuelerversuch => "Schülerversuch", ExperimentKind.Demonstrationsversuch => "Demonstrationsversuch", _ => "Lehrerversuch", }; public static ExperimentKind FromLabel(string? label) => label switch { "Schülerversuch" => ExperimentKind.Schuelerversuch, "Demonstrationsversuch" => ExperimentKind.Demonstrationsversuch, _ => ExperimentKind.Lehrerversuch, }; /// Für die KI-Antwort, die den unmarkierten Enum-Namen ("Schuelerversuch") statt der /// deutschen Anzeige-Bezeichnung liefert (siehe ai-backend/gbu.php "Antwortformat"). public static ExperimentKind FromWireValue(string? value) => Enum.TryParse(value, ignoreCase: true, out var kind) ? kind : ExperimentKind.Lehrerversuch; } public static class GhsPictogramDisplay { private static readonly (GhsPictogram Value, string Code, string Label)[] All = [ (GhsPictogram.Explosive, "GHS01", "Explosionsgefährlich"), (GhsPictogram.Flammable, "GHS02", "Entzündbar"), (GhsPictogram.Oxidizing, "GHS03", "Brandfördernd"), (GhsPictogram.CompressedGas, "GHS04", "Gase unter Druck"), (GhsPictogram.Corrosive, "GHS05", "Ätzend"), (GhsPictogram.Toxic, "GHS06", "Giftig"), (GhsPictogram.Harmful, "GHS07", "Gesundheitsschädlich/Reizend"), (GhsPictogram.HealthHazard, "GHS08", "Gesundheitsgefährdend"), (GhsPictogram.Environmental, "GHS09", "Umweltgefährdend"), ]; public static string Code(GhsPictogram value) => All.First(o => o.Value == value).Code; public static string Label(GhsPictogram value) => All.First(o => o.Value == value).Label; public static GhsPictogram? FromCode(string? code) { if (string.IsNullOrWhiteSpace(code)) return null; foreach (var option in All) if (string.Equals(option.Code, code.Trim(), StringComparison.OrdinalIgnoreCase)) return option.Value; return null; } public static List BuildOptions(IEnumerable? selected = null) { var selectedSet = (selected ?? []).ToHashSet(); return All.Select(o => new GhsPictogramOption(o.Value, $"{o.Code} · {o.Label}") { IsSelected = selectedSet.Contains(o.Value) }).ToList(); } } public partial class GhsPictogramOption(GhsPictogram value, string label) : ObservableObject { public GhsPictogram Value { get; } = value; public string Label { get; } = label; [ObservableProperty] private bool _isSelected; } // ── Zeile im Gefahrstoff-Editor (Wizard-Schritt 2) ─────────────────────────────────────────────── public partial class HazardSubstanceEditItem : ObservableObject { [ObservableProperty] private string _name = ""; [ObservableProperty] private string _cas = ""; [ObservableProperty] private string _amount = ""; [ObservableProperty] private string _signalWord = ""; [ObservableProperty] private string _hStatements = ""; [ObservableProperty] private string _pStatements = ""; [ObservableProperty] private string _activityRestriction = ""; [ObservableProperty] private bool _isResearching; [ObservableProperty] private string _researchError = ""; public ObservableCollection PictogramOptions { get; } public Action? OnRemove { get; set; } /// Vom Wizard gesetzt (nur wenn eine KI-Backend-Anmeldung vorliegt, siehe CanUseAi — der /// Lookup selbst ruft aber keine KI auf, sondern nur die lokale RiSU-Stoffliste auf dem /// Server, siehe ai-backend/substance.php): führt die eigentliche Chemikalien-Recherche aus. /// Wird vor der Anzeige in der UI (Objektinitialisierer in NewSubstanceItem) endgültig /// gesetzt, daher genügt eine einmalig berechnete Eigenschaft ohne Änderungsbenachrichtigung. public Func>? OnResearch { get; set; } public bool CanResearch => OnResearch is not null; public HazardSubstanceEditItem(HazardSubstance? source = null) { PictogramOptions = new ObservableCollection( GhsPictogramDisplay.BuildOptions(source?.GhsPictograms)); if (source is null) return; Name = source.Name; Cas = source.Cas; Amount = source.Amount; SignalWord = source.SignalWord; HStatements = source.HStatements; PStatements = source.PStatements; ActivityRestriction = source.ActivityRestriction; } [RelayCommand] private void Remove() => OnRemove?.Invoke(this); [RelayCommand] private async Task Research() { if (OnResearch is null) return; if (string.IsNullOrWhiteSpace(Name)) { ResearchError = "Stoffname eingeben."; return; } ResearchError = ""; IsResearching = true; try { var result = await OnResearch(Name.Trim()); Name = result.Name; Cas = result.Cas; SignalWord = result.SignalWord; HStatements = result.HStatements; PStatements = result.PStatements; ActivityRestriction = result.ActivityRestriction; var pictograms = result.GhsPictograms .Select(GhsPictogramDisplay.FromCode).Where(p => p.HasValue).Select(p => p!.Value).ToHashSet(); foreach (var option in PictogramOptions) option.IsSelected = pictograms.Contains(option.Value); } catch (AiBackendException ex) { ResearchError = ex.Message; } finally { IsResearching = false; } } public HazardSubstance ToModel() => new() { Name = Name.Trim(), Cas = Cas.Trim(), Amount = Amount.Trim(), SignalWord = SignalWord.Trim(), HStatements = HStatements.Trim(), PStatements = PStatements.Trim(), ActivityRestriction = ActivityRestriction.Trim(), GhsPictograms = PictogramOptions.Where(o => o.IsSelected).Select(o => o.Value).ToList(), }; } // ── Wizard: Gefährdungsbeurteilung anlegen/bearbeiten ──────────────────────────────────────────── /// /// Mehrschrittiger Assistent für eine Gefährdungsbeurteilung zu einem Experiment (Nutzerwunsch /// neben 4.2 "Anhänge je Stunde"). Persistiert NICHT selbst — der Aufrufer (LessonDialog) /// serialisiert zu JSON und hängt es über die bestehende /// Anhang-Infrastruktur an die Lesson an (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2). /// KI-Unterstützung ist optional: ohne // /// bleibt false und der Assistent funktioniert /// rein manuell. /// public partial class HazardAssessmentWizardViewModel : ObservableObject { private static readonly string[] StepTitlesArray = ["Basisdaten", "Gefahrstoffe", "Gefährdungen & Schutzmaßnahmen", "Erste Hilfe & Entsorgung", "Zusammenfassung"]; private readonly AiPlanningService? _ai; private readonly AiSettingsService? _aiSettings; private readonly Unit? _unit; private readonly Lesson? _lesson; [ObservableProperty] private int _stepIndex; [ObservableProperty] private string _title = ""; [ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _groupLabel = ""; [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _kindName = ExperimentKindDisplay.Options[0]; [ObservableProperty] private string _procedure = ""; public ObservableCollection Substances { get; } = []; public bool HasSubstances => Substances.Count > 0; [ObservableProperty] private string _newHazard = ""; public ObservableCollection Hazards { get; } = []; [ObservableProperty] private string _newProtectiveMeasure = ""; public ObservableCollection ProtectiveMeasures { get; } = []; [ObservableProperty] private string _firstAid = ""; [ObservableProperty] private string _disposal = ""; [ObservableProperty] private string _notes = ""; [ObservableProperty] private bool _isAiAssisted; [ObservableProperty] private bool _isAiBusy; [ObservableProperty] private string _aiError = ""; public string AiButtonLabel => IsAiBusy ? "KI fragt..." : "🤖 KI-Entwurf erstellen"; public bool CanUseAi => _ai is not null && _aiSettings is { Enabled: true, IsLoggedIn: true } && _unit is not null && _lesson is not null; public string[] StepTitles => StepTitlesArray; public string StepTitle => StepTitles[StepIndex]; public string StepProgressLabel => $"Schritt {StepIndex + 1} von {StepTitles.Length}"; public int StepCount => StepTitles.Length; public bool IsFirstStep => StepIndex == 0; public bool IsLastStep => StepIndex == StepTitles.Length - 1; public bool IsBasicsStep => StepIndex == 0; public bool IsSubstancesStep => StepIndex == 1; public bool IsHazardsStep => StepIndex == 2; public bool IsFirstAidStep => StepIndex == 3; public bool IsSummaryStep => StepIndex == 4; public string[] KindOptions => ExperimentKindDisplay.Options; public string DialogTitle => _editing ? "Gefährdungsbeurteilung bearbeiten" : "Gefährdungsbeurteilung erstellen"; private readonly bool _editing; public HazardAssessment? Result { get; private set; } public HazardAssessmentWizardViewModel(HazardAssessment? editing, string defaultGroupLabel, AiPlanningService? ai = null, AiSettingsService? aiSettings = null, Unit? unit = null, Lesson? lesson = null) { _ai = ai; _aiSettings = aiSettings; _unit = unit; _lesson = lesson; Substances.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasSubstances)); GroupLabel = defaultGroupLabel; _editing = editing is not null; if (editing is null) return; Title = editing.Title; GroupLabel = editing.GroupLabel; DateText = editing.Date?.ToString("dd.MM.yyyy") ?? ""; KindName = ExperimentKindDisplay.Label(editing.Kind); Procedure = editing.Procedure; foreach (var s in editing.Substances) Substances.Add(NewSubstanceItem(s)); foreach (var h in editing.Hazards) Hazards.Add(h); foreach (var m in editing.ProtectiveMeasures) ProtectiveMeasures.Add(m); FirstAid = editing.FirstAid; Disposal = editing.Disposal; Notes = editing.Notes; IsAiAssisted = editing.IsAiAssisted; } partial void OnIsAiBusyChanged(bool value) => OnPropertyChanged(nameof(AiButtonLabel)); partial void OnStepIndexChanged(int value) { OnPropertyChanged(nameof(StepTitle)); OnPropertyChanged(nameof(StepProgressLabel)); OnPropertyChanged(nameof(IsFirstStep)); OnPropertyChanged(nameof(IsLastStep)); OnPropertyChanged(nameof(IsBasicsStep)); OnPropertyChanged(nameof(IsSubstancesStep)); OnPropertyChanged(nameof(IsHazardsStep)); OnPropertyChanged(nameof(IsFirstAidStep)); OnPropertyChanged(nameof(IsSummaryStep)); } private HazardSubstanceEditItem NewSubstanceItem(HazardSubstance? source = null) { var item = new HazardSubstanceEditItem(source) { OnRemove = RemoveSubstance, OnResearch = CanUseAi ? ResearchSubstanceAsync : null, }; return item; } /// Führt die eigentliche Chemikalien-Recherche aus — ein reiner Datenbank-Lookup gegen die /// lokale RiSU-Stoffliste auf dem Server (ai-backend/stoffliste.php), keine KI beteiligt und /// damit auch kein IsAiAssisted-Hinweis nötig. Die Anmeldung läuft trotzdem über dieselbe /// KI-Backend-Sitzung wie die echten KI-Funktionen (gemeinsamer Host/Token). private async Task ResearchSubstanceAsync(string substanceName) { var token = _aiSettings!.GetToken() ?? throw new AiBackendException("Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden."); return await _ai!.RequestSubstanceResearchAsync(substanceName, token); } [RelayCommand] private void AddSubstance() => Substances.Add(NewSubstanceItem()); private void RemoveSubstance(HazardSubstanceEditItem item) => Substances.Remove(item); [RelayCommand] private void AddHazard() { if (string.IsNullOrWhiteSpace(NewHazard)) return; Hazards.Add(NewHazard.Trim()); NewHazard = ""; } [RelayCommand] private void RemoveHazard(string? hazard) { if (hazard is not null) Hazards.Remove(hazard); } [RelayCommand] private void AddProtectiveMeasure() { if (string.IsNullOrWhiteSpace(NewProtectiveMeasure)) return; ProtectiveMeasures.Add(NewProtectiveMeasure.Trim()); NewProtectiveMeasure = ""; } [RelayCommand] private void RemoveProtectiveMeasure(string? measure) { if (measure is not null) ProtectiveMeasures.Remove(measure); } [RelayCommand] private void Next() { if (IsFirstStep && string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; return; } TitleError = ""; if (!IsLastStep) StepIndex++; } [RelayCommand] private void Back() { if (!IsFirstStep) StepIndex--; } /// /// Fragt einen KI-Entwurf zu genau dieser Lesson ab (Thema + Verlaufsplan als Kontext) und /// übernimmt ihn in die Wizard-Felder — überschreibt dabei bewusst den aktuellen Stand, damit /// nach einem erneuten Klick klar ist, was tatsächlich von der KI kommt. Die Lehrkraft prüft /// und passt danach in den einzelnen Schritten an; der Rechtssicherheits-Hinweis wird über /// in den PDF-Export übernommen. /// [RelayCommand] private async Task RequestAiDraft() { if (!CanUseAi) return; var token = _aiSettings!.GetToken(); if (token is null) { AiError = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden."; return; } AiError = ""; IsAiBusy = true; try { var aiLesson = new AiLesson { Id = _lesson!.Id, Date = _lesson.Date, LessonNumber = _lesson.LessonNumber, Topic = _lesson.Topic, StartTime = _lesson.StartTime, Phases = _lesson.Phases.Select(p => new AiPhaseStep { Name = p.Name, DurationMinutes = p.DurationMinutes, Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, }).ToList(), }; var response = await _ai!.RequestHazardAssessmentDraftAsync(_unit!, aiLesson, token); ApplyAiResponse(response); } catch (AiBackendException ex) { AiError = ex.Message; } finally { IsAiBusy = false; } } private void ApplyAiResponse(AiHazardAssessmentResponse response) { if (string.IsNullOrWhiteSpace(Title)) Title = _lesson?.Topic ?? ""; KindName = ExperimentKindDisplay.Label(ExperimentKindDisplay.FromWireValue(response.ExperimentKind)); Procedure = response.Procedure; Substances.Clear(); foreach (var s in response.Substances) Substances.Add(NewSubstanceItem(new HazardSubstance { Name = s.Name, Amount = s.Amount, HStatements = s.HStatements, PStatements = s.PStatements, GhsPictograms = s.GhsPictograms .Select(GhsPictogramDisplay.FromCode) .Where(p => p.HasValue) .Select(p => p!.Value) .ToList(), })); Hazards.Clear(); foreach (var h in response.Hazards) Hazards.Add(h); ProtectiveMeasures.Clear(); foreach (var m in response.ProtectiveMeasures) ProtectiveMeasures.Add(m); FirstAid = response.FirstAid; Disposal = response.Disposal; IsAiAssisted = true; } [RelayCommand] private void Save() { if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; StepIndex = 0; return; } TitleError = ""; DateOnly? date = null; if (!string.IsNullOrWhiteSpace(DateText) && DateOnly.TryParseExact(DateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) date = parsed; Result = new HazardAssessment { Title = Title.Trim(), GroupLabel = GroupLabel.Trim(), Date = date, Kind = ExperimentKindDisplay.FromLabel(KindName), Procedure = Procedure.Trim(), Substances = Substances.Select(s => s.ToModel()).ToList(), Hazards = Hazards.ToList(), ProtectiveMeasures = ProtectiveMeasures.ToList(), FirstAid = FirstAid.Trim(), Disposal = Disposal.Trim(), Notes = Notes.Trim(), IsAiAssisted = IsAiAssisted, }; } }