Files
adminandClaude Sonnet 5 7b660c7152
CI / build-and-test (push) Canceled after 0s
feat: Planungsideen-Feld je Stunde + persistierter Materialerstellungs-Prompt
Lesson bekommt ein neues Freitextfeld PlanningIdeas fuer grobe Ideen vor der
Feinplanung, sichtbar im LessonDialog und als Kontext an die KI-Planung
(Backend + MCP) durchgereicht. LessonPhaseStep.MaterialPrompt speichert den
beim Uebernehmen einer KI-Stunde erzeugten Materialerstellungs-Prompt dauerhaft,
statt ihn nur einmalig im AiAssistDialog anzuzeigen - im Verlaufsplan-Editor
ueber einen neuen Kopieren-Button je Phase erneut nutzbar, auch ueber MCP
lesbar/schreibbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:41:13 +02:00

276 lines
12 KiB
C#

using Avalonia.Controls;
using Avalonia.Input.Platform;
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 LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
using System.Globalization;
namespace LehrerApp.Desktop.Views.Groups;
public partial class LessonDialog : Window
{
public LessonDialog() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is LessonDialogViewModel vm)
vm.OnPickAlternativePath = ShowAlternativePathDialog;
}
private async Task<AlternativeLessonPath?> ShowAlternativePathDialog(Guid? currentId)
{
var dialogVm = new AlternativePathDialogViewModel(
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(), currentId);
var dialog = new AlternativePathDialog { DataContext = dialogVm };
var ok = await dialog.ShowDialog<bool>(this);
return ok ? dialogVm.Result : null;
}
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is LessonDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e)
{
if (DataContext is LessonDialogViewModel vm) vm.DiscardUnsavedAttachments();
Close(false);
}
/// Kopiert den beim letzten KI-"Übernehmen" gespeicherten Materialerstellungs-Prompt (4.5.36,
/// Nachtrag zu 4.5.20) erneut in die Zwischenablage — derselbe Mechanismus wie im AiAssistDialog,
/// hier aber für einen bereits gespeicherten, nicht mehr nur transienten Vorschlag.
private async void OnCopyMaterialPrompt(object? sender, RoutedEventArgs e)
{
if (sender is not Button { DataContext: PhaseStepEditItem item } || string.IsNullOrWhiteSpace(item.MaterialPrompt))
return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(item.MaterialPrompt);
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Prompt in die Zwischenablage kopiert.");
}
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.Cas, s.Amount, string.Join(", ", s.GhsPictograms.Select(GhsPictogramDisplay.Code)),
s.SignalWord, s.HStatements, s.PStatements, s.ActivityRestriction)).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
/// AiAssistDialog speichert direkt ins Repository — dieser Dialog schließt sich danach mit dem
/// frisch geladenen Stand, statt seine eigenen (jetzt veralteten) Feldwerte zu speichern.
private async void OnAiAssist(object? s, RoutedEventArgs e)
{
if (DataContext is not LessonDialogViewModel vm || vm.EditingLesson is null) return;
var unitRepo = App.Services.GetRequiredService<IUnitRepository>();
var unit = unitRepo.GetById(vm.UnitId);
if (unit is null) return;
var lessonRepo = App.Services.GetRequiredService<ILessonRepository>();
var currentLesson = lessonRepo.GetByUnit(vm.UnitId).FirstOrDefault(l => l.Id == vm.EditingLesson.Id);
if (currentLesson is null) return;
var dialogVm = new AiAssistDialogViewModel(
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<AiSettingsService>(),
lessonRepo, unit, focusLesson: currentLesson);
var dialog = new AiAssistDialog { DataContext = dialogVm };
var ok = await dialog.ShowDialog<bool>(this);
if (!ok) return;
var updated = lessonRepo.GetByUnit(vm.UnitId).FirstOrDefault(l => l.Id == vm.EditingLesson.Id);
if (updated is not null) vm.MarkAppliedExternally(updated);
Close(true);
}
/// Deckt die Lücke ab, dass der Stundenplan eine Stunde nur über Datum+Stundennummer findet
/// (kein gespeichertes Verknüpfungsfeld) — eine per JSON-Import oder KI ohne Stundennummer
/// angelegte Stunde taucht dort nie auf und würde sonst beim Klick auf den Termin dupliziert.
/// Statt neu anzulegen, wird die gefundene vorhandene Stunde auf diesen Termin umgehängt.
private async void OnFixIt(object? s, RoutedEventArgs e)
{
if (DataContext is not LessonDialogViewModel vm) return;
if (!DateOnly.TryParseExact(vm.DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{
App.Services.GetRequiredService<NotificationService>()
.ShowError("Bitte zuerst ein gültiges Datum eintragen.");
return;
}
var lessonRepo = App.Services.GetRequiredService<ILessonRepository>();
var candidates = LessonFixItSearch.FindCandidates(lessonRepo, vm.GroupId, date);
if (candidates.Count == 0)
{
App.Services.GetRequiredService<NotificationService>()
.ShowError("Keine passende bestehende Stunde gefunden.");
return;
}
Lesson chosen;
if (candidates.Count == 1)
{
var info = new ConfirmDialogInfo
{
Title = "Vorhandene Stunde verknüpfen?",
Message = $"Vorhandene Stunde „{candidates[0].Topic}“ vom {candidates[0].Date:dd.MM.yyyy} gefunden. " +
"Mit diesem Termin verknüpfen, statt eine neue Stunde anzulegen?",
ConfirmText = "Verknüpfen",
};
var confirm = new ConfirmDialog { DataContext = info };
if (!await confirm.ShowDialog<bool>(this)) return;
chosen = candidates[0];
}
else
{
var pickerVm = new LinkExistingLessonDialogViewModel(candidates,
App.Services.GetRequiredService<IUnitRepository>());
var picker = new LinkExistingLessonDialog { DataContext = pickerVm };
if (!await picker.ShowDialog<bool>(this) || pickerVm.Result is not { } picked) return;
chosen = picked;
}
chosen.UnitId = vm.UnitId;
chosen.Date = date;
chosen.LessonNumber = vm.LessonNumber;
lessonRepo.Save(chosen);
vm.MarkAppliedExternally(chosen);
Close(true);
}
}