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:
@@ -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>
|
||||
/// 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
|
||||
|
||||
@@ -73,6 +73,23 @@ public sealed record DocumentationPrintEntry(
|
||||
bool IsConfidential,
|
||||
bool IsDraft);
|
||||
|
||||
public sealed record HazardAssessmentPrintData(
|
||||
string Title,
|
||||
string GroupLabel,
|
||||
string DateDisplay,
|
||||
string KindLabel,
|
||||
string Procedure,
|
||||
IReadOnlyList<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>
|
||||
/// 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:
|
||||
@@ -346,8 +363,95 @@ public sealed class PdfExportService
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
public byte[] BuildHazardAssessmentPdf(HazardAssessmentPrintData data) =>
|
||||
Document.Create(container => container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(36);
|
||||
page.DefaultTextStyle(style => style.FontSize(9));
|
||||
|
||||
page.Header().Element(header => Header(header, $"Gefährdungsbeurteilung · {data.Title}",
|
||||
$"{data.GroupLabel} · {data.KindLabel} · {data.DateDisplay}"));
|
||||
|
||||
page.Content().PaddingVertical(10).Column(column =>
|
||||
{
|
||||
column.Spacing(10);
|
||||
if (data.IsAiAssisted) column.Item().Element(AiDisclaimerBanner);
|
||||
if (!string.IsNullOrWhiteSpace(data.Procedure))
|
||||
column.Item().Element(c => TextSection(c, "Durchführung", data.Procedure));
|
||||
if (data.Substances.Count > 0)
|
||||
column.Item().Element(c => SubstanceTable(c, data.Substances));
|
||||
if (data.Hazards.Count > 0)
|
||||
column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards));
|
||||
if (data.ProtectiveMeasures.Count > 0)
|
||||
column.Item().Element(c => BulletSection(c, "Schutzmaßnahmen", data.ProtectiveMeasures));
|
||||
if (!string.IsNullOrWhiteSpace(data.FirstAid))
|
||||
column.Item().Element(c => TextSection(c, "Erste Hilfe", data.FirstAid));
|
||||
if (!string.IsNullOrWhiteSpace(data.Disposal))
|
||||
column.Item().Element(c => TextSection(c, "Entsorgung", data.Disposal));
|
||||
if (!string.IsNullOrWhiteSpace(data.Notes))
|
||||
column.Item().Element(c => TextSection(c, "Weitere Hinweise", data.Notes));
|
||||
});
|
||||
|
||||
page.Footer().Element(Footer);
|
||||
})).GeneratePdf();
|
||||
|
||||
// ── Bausteine ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static void AiDisclaimerBanner(IContainer container) =>
|
||||
container.Background(Colors.Orange.Lighten4).Padding(8).Text(
|
||||
"⚠️ Enthält KI-generierte Anteile — kein rechtssicheres Dokument. Vor Verwendung " +
|
||||
"eigenverantwortlich prüfen, insbesondere H-/P-Sätze und Mengenangaben gegen das " +
|
||||
"Sicherheitsdatenblatt.").FontSize(9).FontColor(Colors.Orange.Darken3).SemiBold();
|
||||
|
||||
private static void TextSection(IContainer container, string title, string text) =>
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Item().Text(title).FontSize(11).SemiBold();
|
||||
column.Item().Text(text);
|
||||
});
|
||||
|
||||
private static void BulletSection(IContainer container, string title, IReadOnlyList<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>
|
||||
private static void Bar(IContainer container, double percent, string color) =>
|
||||
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.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
@@ -655,9 +656,11 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
private readonly IAlternativeLessonPathRepository _alternativePaths;
|
||||
private readonly ITimetableSlotRepository _timetableSlots;
|
||||
private readonly PeriodScheduleService _periodSchedule;
|
||||
private readonly IAttachmentStorage _attachmentStorage;
|
||||
private readonly Guid _unitId;
|
||||
private readonly Guid _groupId;
|
||||
private readonly Lesson? _editingLesson;
|
||||
private readonly List<string> _newlyUploadedStorageIds = [];
|
||||
|
||||
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
||||
[ObservableProperty] private int? _lessonNumber;
|
||||
@@ -684,6 +687,10 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
public string[] ShorthandSuggestions { get; }
|
||||
public ObservableCollection<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
|
||||
/// alternativen Ablauf, dem eine Phase zugeordnet werden soll (Auswahl oder Neuanlage
|
||||
/// per Combobox-Dialog). null zurückgegeben = abgebrochen.
|
||||
@@ -713,11 +720,13 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
|
||||
public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes,
|
||||
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
||||
PeriodScheduleService periodSchedule, Guid unitId, Guid groupId, string groupName, string subjectName,
|
||||
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
|
||||
Guid unitId, Guid groupId, string groupName, string subjectName,
|
||||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
||||
{
|
||||
_lessons = lessons; _alternativePaths = alternativePaths;
|
||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||
_attachmentStorage = attachmentStorage;
|
||||
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
|
||||
GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
|
||||
? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}";
|
||||
@@ -746,6 +755,8 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
Reflection = editingLesson.Reflection ?? "";
|
||||
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
|
||||
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
|
||||
foreach (var att in editingLesson.Attachments)
|
||||
Attachments.Add(new AttachmentItem(att.StorageId, att.FileName, att.SizeBytes, att.UploadedAt));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -925,6 +936,39 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
_ => "#B71C1C", // Dunkelrot: deutlich überplant
|
||||
};
|
||||
|
||||
// ── Anhänge ────────────────────────────────────────────────────────────
|
||||
|
||||
public void AddAttachment(string fileName, Stream content)
|
||||
{
|
||||
AttachmentError = "";
|
||||
if (content.Length > IAttachmentStorage.MaxSizeBytes)
|
||||
{
|
||||
AttachmentError = $"Datei zu groß (max. {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB).";
|
||||
return;
|
||||
}
|
||||
var storageId = _attachmentStorage.Upload(fileName, content);
|
||||
_newlyUploadedStorageIds.Add(storageId);
|
||||
Attachments.Add(new AttachmentItem(storageId, fileName, content.Length, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
public Stream? OpenAttachment(AttachmentItem item) => _attachmentStorage.OpenRead(item.StorageId);
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveAttachment(AttachmentItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
_attachmentStorage.Delete(item.StorageId);
|
||||
_newlyUploadedStorageIds.Remove(item.StorageId);
|
||||
Attachments.Remove(item);
|
||||
}
|
||||
|
||||
/// Vom Code-Behind beim Abbrechen aufgerufen: neu hochgeladene, nie gespeicherte Anhänge
|
||||
/// wieder entfernen, damit keine verwaisten Blobs in der Datenbank zurückbleiben.
|
||||
public void DiscardUnsavedAttachments()
|
||||
{
|
||||
foreach (var id in _newlyUploadedStorageIds) _attachmentStorage.Delete(id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
@@ -960,7 +1004,12 @@ public partial class LessonDialogViewModel : ObservableObject
|
||||
Result.HomeworkCheckDismissed = HomeworkCheckDismissed;
|
||||
Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim();
|
||||
Result.Status = LessonStatusDisplay.FromName(StatusName);
|
||||
Result.Attachments = Attachments.Select(a => new DocumentAttachment
|
||||
{
|
||||
StorageId = a.StorageId, FileName = a.FileName, SizeBytes = a.SizeBytes, UploadedAt = a.UploadedAt,
|
||||
}).ToList();
|
||||
_lessons.Save(Result);
|
||||
_newlyUploadedStorageIds.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -398,6 +398,40 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
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)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanEditLayout));
|
||||
@@ -417,6 +451,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
AddPlanCommand.NotifyCanExecuteChanged();
|
||||
EditPlanCommand.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 const string HazardAssessmentSuffix = ".gbu.json";
|
||||
|
||||
public string StorageId { get; } = storageId;
|
||||
public string FileName { get; } = fileName;
|
||||
public long SizeBytes { get; } = sizeBytes;
|
||||
public DateTime UploadedAt { get; } = uploadedAt;
|
||||
public string SizeDisplay => $"{SizeBytes / 1024.0:0} KB";
|
||||
/// Strukturierter Gefährdungsbeurteilungs-Anhang (siehe HazardAssessment) statt einer
|
||||
/// beliebigen hochgeladenen Datei — steuert im Anhang-Editor, ob "Öffnen"+"PDF" statt des
|
||||
/// generischen "Speichern" angeboten wird.
|
||||
public bool IsHazardAssessment => FileName.EndsWith(HazardAssessmentSuffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public class TagChip(string text)
|
||||
|
||||
@@ -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"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
xmlns:vms="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.LessonDialog"
|
||||
x:DataType="vm:LessonDialogViewModel"
|
||||
Title="{Binding DialogTitle}"
|
||||
@@ -139,6 +140,43 @@
|
||||
<TextBox Text="{Binding Reflection}" AcceptsReturn="True" Height="56" TextWrapping="Wrap"
|
||||
PlaceholderText="Nach der Stunde: was lief gut, was nicht?"/>
|
||||
</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>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
@@ -38,7 +40,136 @@ public partial class LessonDialog : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
|
||||
private void OnCancel(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is LessonDialogViewModel vm) vm.DiscardUnsavedAttachments();
|
||||
Close(false);
|
||||
}
|
||||
|
||||
private async void OnAddAttachment(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not LessonDialogViewModel vm) return;
|
||||
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Anhang auswählen", AllowMultiple = false,
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
|
||||
await using var stream = await files[0].OpenReadAsync();
|
||||
vm.AddAttachment(files[0].Name, stream);
|
||||
}
|
||||
|
||||
private async void OnOpenAttachment(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not LessonDialogViewModel vm) return;
|
||||
if (sender is not Button { Tag: AttachmentItem item }) return;
|
||||
|
||||
using var source = vm.OpenAttachment(item);
|
||||
if (source is null) return;
|
||||
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Anhang speichern unter", SuggestedFileName = item.FileName,
|
||||
});
|
||||
if (file is null) return;
|
||||
|
||||
await using var target = await file.OpenWriteAsync();
|
||||
await source.CopyToAsync(target);
|
||||
}
|
||||
|
||||
private async void OnCreateHazardAssessment(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not LessonDialogViewModel vm) return;
|
||||
|
||||
var (unit, contextLesson, groupName) = BuildHazardAssessmentContext(vm);
|
||||
var dialogVm = new HazardAssessmentWizardViewModel(null, groupName,
|
||||
App.Services.GetRequiredService<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
|
||||
/// ü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<ITimetableSlotRepository>(),
|
||||
App.Services.GetRequiredService<PeriodScheduleService>(),
|
||||
App.Services.GetRequiredService<IAttachmentStorage>(),
|
||||
unitId, groupId, vm.GroupLabel, vm.SubjectName,
|
||||
materialSuggestions, shorthandHistorySuggestions, editingLesson);
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
<Button Grid.Column="2" Content="Löschen" Command="{Binding DeletePlanCommand}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</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>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
Reference in New Issue
Block a user