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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:13:14 +02:00
co-authored by Claude Sonnet 5
parent 331033db5c
commit 038337997f
29 changed files with 1886 additions and 22 deletions
@@ -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>