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
@@ -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();
}
}