using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.AiPlanning; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Students; using System.Collections.ObjectModel; using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Groups; // ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ─── public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing, int? NewPeriod = null, bool SplitDoubleLesson = false); public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate); public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting) { public string Summary => $"{Created} Stunde(n) angelegt" + (SkippedHoliday > 0 ? $", {SkippedHoliday} durch Ferien/Feiertage übersprungen" : "") + (SkippedExisting > 0 ? $", {SkippedExisting} bereits vorhanden" : "") + "."; } // ── Tab-ViewModel: Unterrichtsplanung (4.1 Einheiten / 4.2 Einzelstunden) ──── public partial class PlanningTabViewModel : ObservableObject { private readonly IUnitRepository _units; private readonly ILessonRepository _lessons; private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; private readonly ICompetencyDomainRepository _competencyDomains; private readonly AiSettingsService _aiSettings; private readonly IParticipationSessionRepository _participationSessions; private Guid _groupId; public Guid GroupId => _groupId; public Guid? SubjectId { get; private set; } public int GradeLevel { get; private set; } public string SubjectName { get; private set; } = ""; public string GroupLabel { get; private set; } = ""; [ObservableProperty] private UnitSummary? _selectedUnit; [ObservableProperty] private LessonSummary? _selectedLesson; [ObservableProperty] private bool _isReadOnly; // Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen // Gruppenwechsel/Laden kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal // einen Binding-Fehler loggen (siehe GroupDetailViewModel.IsDifferentiated für dasselbe Muster). public string SelectedUnitTitleSuffix => SelectedUnit is null ? "" : $" – {SelectedUnit.Title}"; public bool HasUnitSelection => SelectedUnit is not null; public bool HasLessonSelection => SelectedLesson is not null; public bool CanImportUnitPlanning => !IsReadOnly; public bool CanImportLessonPlanning => !IsReadOnly && SelectedUnit is not null; public ObservableCollection Units { get; } = []; public ObservableCollection Lessons { get; } = []; /// Aus den Material-/Kurzsymbol-Werten aller bereits vorhandenen Stunden-Phasen der Gruppe /// zusammengestellt (4.2.2 Autovervollständigung im Verlaufsplan-Editor). Alternative Abläufe /// kommen seit dem Katalog-Redesign nicht mehr aus der Stundenhistorie, sondern direkt aus /// IAlternativeLessonPathRepository (siehe LessonDialogViewModel). public List KnownMaterials { get; private set; } = []; public List KnownShorthands { get; private set; } = []; public Func>? OnAddUnit { get; set; } public Func>? OnEditUnit { get; set; } public Func>? OnConfirmDeleteUnit { get; set; } public Func>? OnPickCopyTarget { get; set; } public Func, List, Task>? OnAddLesson { get; set; } public Func, List, Task>? OnEditLesson { get; set; } public Func>? OnConfirmDeleteLesson { get; set; } public Func>? OnPickMoveTarget { get; set; } public Func>? OnPickUnitChange { get; set; } public Func? OnShowLesson { get; set; } public Func>? OnGenerateLessonSeries { get; set; } public Func>? OnAiAssist { get; set; } public Action? OnNotify { get; set; } public Action? OnError { get; set; } public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, ISubjectRepository subjects, ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings, IParticipationSessionRepository participationSessions) { _units = units; _lessons = lessons; _groups = groups; _subjects = subjects; _competencyDomains = competencyDomains; _aiSettings = aiSettings; _participationSessions = participationSessions; } public void Initialize(Guid groupId, bool isReadOnly = false) { _groupId = groupId; IsReadOnly = isReadOnly; var group = _groups.GetById(groupId); SubjectId = group?.SubjectId; GradeLevel = group?.GradeLevel ?? 0; SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : ""; GroupLabel = group?.Name ?? ""; LoadUnits(preferActive: true); } private void LoadUnits(bool preferActive = false) { var selectedId = SelectedUnit?.Id; Units.Clear(); var materials = new HashSet(); var shorthands = new HashSet(); foreach (var unit in _units.GetByGroup(_groupId)) { var unitLessons = _lessons.GetByUnit(unit.Id); foreach (var l in unitLessons) foreach (var p in l.Phases) { if (!string.IsNullOrWhiteSpace(p.Material)) materials.Add(p.Material); if (!string.IsNullOrWhiteSpace(p.Shorthand)) shorthands.Add(p.Shorthand); } Units.Add(new UnitSummary(unit, unitLessons)); } KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList(); KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList(); // Beim ersten Öffnen bzw. beim Wechsel aus dem Stundenplan steht die laufende Einheit im // Fokus. Eine bewusste Auswahl innerhalb derselben Gruppe bleibt bei Refreshes erhalten. SelectedUnit = !preferActive && selectedId is not null ? Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault() : Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault(); } partial void OnSelectedUnitChanged(UnitSummary? value) { LoadLessons(); OnPropertyChanged(nameof(SelectedUnitTitleSuffix)); OnPropertyChanged(nameof(HasUnitSelection)); OnPropertyChanged(nameof(CanImportLessonPlanning)); EditUnitCommand.NotifyCanExecuteChanged(); DeleteUnitCommand.NotifyCanExecuteChanged(); CopyUnitCommand.NotifyCanExecuteChanged(); AddLessonCommand.NotifyCanExecuteChanged(); GenerateLessonSeriesCommand.NotifyCanExecuteChanged(); AiAssistCommand.NotifyCanExecuteChanged(); } private void LoadLessons() { var selectedId = SelectedLesson?.Id; Lessons.Clear(); if (SelectedUnit is not null) foreach (var l in _lessons.GetByUnit(SelectedUnit.Id)) Lessons.Add(new LessonSummary(l)); SelectedLesson = Lessons.FirstOrDefault(l => l.Id == selectedId); } partial void OnSelectedLessonChanged(LessonSummary? value) { OnPropertyChanged(nameof(HasLessonSelection)); ShowLessonCommand.NotifyCanExecuteChanged(); EditLessonCommand.NotifyCanExecuteChanged(); DeleteLessonCommand.NotifyCanExecuteChanged(); MoveLessonCommand.NotifyCanExecuteChanged(); ChangeLessonUnitCommand.NotifyCanExecuteChanged(); AdvanceLessonStatusCommand.NotifyCanExecuteChanged(); } partial void OnIsReadOnlyChanged(bool value) { OnPropertyChanged(nameof(CanImportUnitPlanning)); OnPropertyChanged(nameof(CanImportLessonPlanning)); } public void RefreshPlanning(Guid? selectUnitId = null, Guid? selectLessonId = null) { selectUnitId ??= SelectedUnit?.Id; selectLessonId ??= SelectedLesson?.Id; LoadUnits(); if (selectUnitId is Guid unitId) SelectedUnit = Units.FirstOrDefault(u => u.Id == unitId) ?? SelectedUnit; if (selectLessonId is Guid lessonId) SelectedLesson = Lessons.FirstOrDefault(l => l.Id == lessonId); } private bool HasSelectedUnit() => SelectedUnit is not null; private bool HasSelectedLesson() => SelectedLesson is not null; private bool CanAiAssist() => SelectedUnit is not null && _aiSettings.Enabled; // ── Einheiten (4.1) ──────────────────────────────────────────────────────── [RelayCommand] private async Task AddUnit() { if (OnAddUnit is null) return; if (await OnAddUnit(_groupId)) LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task EditUnit() { if (OnEditUnit is null || SelectedUnit is null) return; if (await OnEditUnit(SelectedUnit.Model)) LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task DeleteUnit() { if (OnConfirmDeleteUnit is null || SelectedUnit is null) return; var unit = SelectedUnit; if (!await OnConfirmDeleteUnit(unit)) return; foreach (var l in _lessons.GetByUnit(unit.Id)) _lessons.Delete(l.Id); _units.Delete(unit.Id); LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task CopyUnit() { if (OnPickCopyTarget is null || SelectedUnit is null) return; var unit = SelectedUnit.Model; var target = await OnPickCopyTarget(unit); if (target is null) return; CopyUnitAsTemplate(unit, _lessons.GetByUnit(unit.Id), target.TargetGroupId, target.AnchorDate); LoadUnits(); } /// Kopiert eine Einheit inkl. Stunden in eine andere Gruppe (4.1.4). "Ohne Datumsbezug" /// bedeutet: die relativen Tages-Abstände der Stunden zueinander bleiben erhalten, werden /// aber auf das neu gewählte Startdatum re-verankert statt die alten Kalendertage zu übernehmen. /// Reflexion wird geleert, Status auf "Geplant" zurückgesetzt. GroupId wird auf jeder neuen /// Lesson explizit auf die Zielgruppe gesetzt (siehe docs/Datenmodell.md). private void CopyUnitAsTemplate(Unit source, List sourceLessons, Guid targetGroupId, DateOnly anchorDate) { var earliest = sourceLessons.Count > 0 ? sourceLessons.Min(l => l.Date) : source.StartDate ?? anchorDate; var newUnit = new Unit { GroupId = targetGroupId, Title = source.Title, Competencies = [.. source.Competencies], Status = UnitStatus.Planned, Notes = source.Notes, StartDate = source.StartDate.HasValue ? anchorDate.AddDays(source.StartDate.Value.DayNumber - earliest.DayNumber) : null, EndDate = source.EndDate.HasValue ? anchorDate.AddDays(source.EndDate.Value.DayNumber - earliest.DayNumber) : null, }; _units.Save(newUnit); foreach (var lesson in sourceLessons) { _lessons.Save(new Lesson { UnitId = newUnit.Id, GroupId = targetGroupId, Date = anchorDate.AddDays(lesson.Date.DayNumber - earliest.DayNumber), LessonNumber = lesson.LessonNumber, Topic = lesson.Topic, StartTime = lesson.StartTime, Phases = [.. lesson.Phases.Select(p => new LessonPhaseStep { Name = p.Name, DurationMinutes = p.DurationMinutes, Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, AlternativePathId = p.AlternativePathId, })], Homework = lesson.Homework, Reflection = null, Status = LessonStatus.Planned, }); } } // ── Einzelstunden (4.2) ─────────────────────────────────────────────────── [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task AddLesson() { if (OnAddLesson is null || SelectedUnit is null) return; if (await OnAddLesson(SelectedUnit.Id, _groupId, KnownMaterials, KnownShorthands)) LoadUnits(); } /// Serienerzeugung von Stunden aus dem Stundenplan (4.2.5) — die eigentliche Logik läuft im /// Dialog (), hier wird nur nachgeladen. [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task GenerateLessonSeries() { if (OnGenerateLessonSeries is null || SelectedUnit is null) return; var result = await OnGenerateLessonSeries(SelectedUnit.Model); if (result is not null) LoadUnits(); } /// KI-gestützte Planungsunterstützung (4.5.9) — die eigentliche Anfrage/Auswertung läuft im /// Dialog (), hier wird nur nachgeladen. [RelayCommand(CanExecute = nameof(CanAiAssist))] private async Task AiAssist() { if (OnAiAssist is null || SelectedUnit is null) return; if (await OnAiAssist(SelectedUnit.Model)) LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task EditLesson() { if (OnEditLesson is null || SelectedLesson is null) return; if (await OnEditLesson(SelectedLesson.Model, KnownMaterials, KnownShorthands)) LoadUnits(); } /// Schreibgeschützte Anzeige des Verlaufsplans für den Einsatz im Unterricht (kein /// Bearbeitungsrisiko). Bewusst ohne Live-Anpassung (Verlängern/Verschieben während des /// Haltens) — das gehört zur zurückgestellten Live-Unterrichtsmodus-Ideensammlung (TODO.md). [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task ShowLesson() { if (OnShowLesson is null || SelectedLesson is null) return; await OnShowLesson(SelectedLesson.Model); } [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task DeleteLesson() { if (OnConfirmDeleteLesson is null || SelectedLesson is null) return; var lesson = SelectedLesson; if (!await OnConfirmDeleteLesson(lesson)) return; _lessons.Delete(lesson.Id); LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task MoveLesson() { if (OnPickMoveTarget is null || SelectedLesson is null) return; var lesson = SelectedLesson.Model; var target = await OnPickMoveTarget(lesson); if (target is null) return; try { MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing, target.NewPeriod); } catch (InvalidOperationException ex) { OnError?.Invoke(ex.Message); return; } LoadUnits(); } /// Verschiebt eine Stunde auf ein neues Datum (4.2.4). Ist "Nachrücken" aktiv, verschieben /// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der /// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden /// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert. private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing, int? newPeriod = null) { new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing); } /// Ordnet eine bereits angelegte Stunde einer anderen Einheit derselben Gruppe zu (Korrektur /// einer im Erstellen-Dialog falsch gewählten Einheit, Nutzer-Feedback) — bisher war das nur /// über Löschen + Neuanlegen möglich. Datum/Stundennummer bleiben unverändert, nur UnitId ändert /// sich. Nach dem Speichern springt die Ansicht direkt zur Ziel-Einheit mit der Stunde /// vorausgewählt, damit sichtbar bleibt, wohin sie verschoben wurde. [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task ChangeLessonUnit() { if (OnPickUnitChange is null || SelectedLesson is null) return; var lesson = SelectedLesson.Model; var targetUnit = await OnPickUnitChange(lesson); if (targetUnit is null) return; lesson.UnitId = targetUnit.Id; _lessons.Save(lesson); RefreshPlanning(selectUnitId: targetUnit.Id, selectLessonId: lesson.Id); } [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private void AdvanceLessonStatus() { if (SelectedLesson is null || SelectedLesson.Model.Status is LessonStatus.Conducted or LessonStatus.Cancelled) return; var lesson = SelectedLesson.Model; lesson.Status = lesson.Status == LessonStatus.Ready ? LessonStatus.Conducted : LessonStatus.Ready; _lessons.Save(lesson); LoadUnits(); } /// Übernimmt Datum + Thema der Stunde in eine neue Mitarbeitssitzung (3.3.1) — verknüpft über /// das bisher ungenutzte Lesson.LessonId-Feld auf ParticipationSession, damit ein zweiter Klick /// auf dieselbe Stunde keine doppelte Sitzung anlegt, sondern nur darauf hinweist. /// /// Prüft dabei zusätzlich auf JEDE bereits an diesem Tag bestehende Sitzung, nicht nur eine /// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback, analog /// ): kommt neben einer /// Doppelstunde noch eine dritte Stunde desselben Tages hinzu (eigene `Lesson`, z.B. durch /// Vertretung), soll das nicht zu einer zweiten Mitarbeitssitzung für den Tag führen. [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private void CreateParticipationSession() { if (SelectedLesson is null) return; var lesson = SelectedLesson.Model; var sessionsForGroup = _participationSessions.GetByGroup(lesson.GroupId); var existing = sessionsForGroup.FirstOrDefault(s => s.LessonId == lesson.Id) ?? sessionsForGroup.FirstOrDefault(s => s.Date == lesson.Date); if (existing is not null) { OnNotify?.Invoke(existing.LessonId == lesson.Id ? "Für diese Stunde existiert bereits eine Sitzung." : "Für diesen Tag existiert bereits eine Sitzung."); return; } _participationSessions.Save(new ParticipationSession { GroupId = lesson.GroupId, Date = lesson.Date, LessonId = lesson.Id, Comment = lesson.Topic, }); OnNotify?.Invoke("Sitzung aus der Stunde erstellt."); } } // ── Anzeige-DTOs ────────────────────────────────────────────────────────────── public class UnitSummary { public Guid Id { get; } public Unit Model { get; } public string Title { get; } public string DateRangeDisplay { get; } public UnitStatus Status { get; } public string StatusLabel { get; } public string StatusColorHex { get; } public string CompetencyCountLabel { get; } public int ConductedCount { get; } public int TotalCount { get; } public double ProgressFraction { get; } public string ProgressText { get; } public UnitSummary(Unit u, List lessons) { Id = u.Id; Model = u; Title = u.Title; DateRangeDisplay = (u.StartDate, u.EndDate) switch { ({ } start, { } end) => $"{start:dd.MM.yyyy} – {end:dd.MM.yyyy}", ({ } start, null) => $"ab {start:dd.MM.yyyy}", (null, { } end) => $"bis {end:dd.MM.yyyy}", _ => "–", }; Status = u.Status; StatusLabel = u.Status switch { UnitStatus.Planned => "Geplant", UnitStatus.Active => "Laufend", UnitStatus.Completed => "Abgeschlossen", _ => "", }; StatusColorHex = u.Status switch { UnitStatus.Planned => "#9E9E9E", UnitStatus.Active => "#FB8C00", UnitStatus.Completed => "#43A047", _ => "#9E9E9E", }; CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)"; // Ausgefallene Stunden zählen weder als gehalten noch als noch zu haltendes Pensum - // sie fallen komplett aus dem Fortschritt heraus, statt den Nenner künstlich zu erhöhen. var countableLessons = lessons.Where(l => l.Status != LessonStatus.Cancelled).ToList(); TotalCount = countableLessons.Count; ConductedCount = countableLessons.Count(l => l.Status == LessonStatus.Conducted); ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount; ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten"; } } public class LessonSummary { public Guid Id { get; } public Lesson Model { get; } public Guid UnitId { get; } public string DateDisplay { get; } public int? LessonNumber { get; } public string Topic { get; } public string StartTimeDisplay { get; } public LessonStatus Status { get; } public string StatusLabel { get; } public string StatusColorHex { get; } public string PhaseCountLabel { get; } public string TotalDurationLabel { get; } public string MaterialsDisplay { get; } public bool HasHomework { get; } public LessonSummary(Lesson l) { Id = l.Id; Model = l; UnitId = l.UnitId; DateDisplay = l.Date.ToString("dd.MM.yyyy"); LessonNumber = l.LessonNumber; Topic = l.Topic; StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–"; Status = l.Status; StatusLabel = LessonStatusDisplay.ToName(l.Status); StatusColorHex = l.Status switch { LessonStatus.Draft => "#78909C", LessonStatus.Ready => "#1976D2", LessonStatus.Conducted => "#43A047", LessonStatus.Cancelled => "#C62828", _ => "#9E9E9E", }; PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen"; var totalMinutes = l.Phases.Sum(p => p.DurationMinutes); TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min."; MaterialsDisplay = string.Join(", ", l.Phases .Select(p => p.Material) .Where(m => !string.IsNullOrWhiteSpace(m)) .Distinct()); HasHomework = !string.IsNullOrWhiteSpace(l.Homework); } } // ── Status-Anzeige (Einheiten/Stunden, analog NiveauDisplay) ────────────────── public static class UnitStatusDisplay { public static string[] Options { get; } = ["Geplant", "Laufend", "Abgeschlossen"]; public static string ToName(UnitStatus s) => s switch { UnitStatus.Active => "Laufend", UnitStatus.Completed => "Abgeschlossen", _ => "Geplant", }; public static UnitStatus FromName(string? name) => name switch { "Laufend" => UnitStatus.Active, "Abgeschlossen" => UnitStatus.Completed, _ => UnitStatus.Planned, }; } public static class LessonStatusDisplay { public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt", "Ausgefallen"]; public static string ToName(LessonStatus s) => s switch { LessonStatus.Draft => "Entwurf", LessonStatus.Ready => "Bereit", LessonStatus.Conducted => "Durchgeführt", LessonStatus.Cancelled => "Ausgefallen", _ => "Geplant", }; public static LessonStatus FromName(string? name) => name switch { "Entwurf" => LessonStatus.Draft, "Bereit" => LessonStatus.Ready, "Durchgeführt" => LessonStatus.Conducted, "Ausgefallen" => LessonStatus.Cancelled, _ => LessonStatus.Planned, }; } // ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ───────────────────── public partial class UnitDialogViewModel : ObservableObject { private readonly IUnitRepository _units; private readonly ICompetencyDomainRepository _competencyDomains; private readonly Guid _groupId; private readonly Guid? _subjectId; private readonly int _gradeLevel; private readonly Unit? _editingUnit; private readonly List _competencyCodes; [ObservableProperty] private string _title = ""; [ObservableProperty] private string _startDateText = ""; [ObservableProperty] private string _endDateText = ""; [ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0]; [ObservableProperty] private string _statusNotice = ""; [ObservableProperty] private string _notes = ""; [ObservableProperty] private bool _isCompetencyPanelOpen; [ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _startDateTextError = ""; [ObservableProperty] private string _endDateTextError = ""; public string[] StatusOptions => UnitStatusDisplay.Options; public ObservableCollection CompetencyTagGroups { get; } = []; public string CompetencySummary => _competencyCodes.Count == 0 ? "Keine Kompetenzen" : $"{_competencyCodes.Count} Kompetenz(en)"; /// Gruppe + Fach kommen von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein /// Fach) — beide zusammen zeigen, da dieselbe Klasse in mehreren Fächern (mehrere /// Lerngruppen mit gleichem Namen) sonst nicht unterscheidbar wäre (Nutzer-Feedback). public string GroupSubjectDisplay { get; } public Unit? Result { get; private set; } public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten"; public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern"; partial void OnStatusNameChanged(string value) { if (UnitStatusDisplay.FromName(value) != UnitStatus.Active) { StatusNotice = ""; return; } var previous = _units.GetByGroup(_groupId) .FirstOrDefault(u => u.Status == UnitStatus.Active && u.Id != _editingUnit?.Id); StatusNotice = previous is null ? "" : $"„{previous.Title}“ wird beim Speichern automatisch abgeschlossen."; } public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains, Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit) { _units = units; _competencyDomains = competencyDomains; _groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel; _editingUnit = editingUnit; GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName) ? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}"; _competencyCodes = editingUnit is not null ? [.. editingUnit.Competencies] : []; BuildCompetencyTagGroups(); if (editingUnit is not null) { Title = editingUnit.Title; StartDateText = editingUnit.StartDate?.ToString("dd.MM.yyyy") ?? ""; EndDateText = editingUnit.EndDate?.ToString("dd.MM.yyyy") ?? ""; StatusName = UnitStatusDisplay.ToName(editingUnit.Status); Notes = editingUnit.Notes ?? ""; } } private void BuildCompetencyTagGroups() { CompetencyTagGroups.Clear(); if (!_subjectId.HasValue) return; var selected = _competencyCodes.ToHashSet(); foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel)) { var group = new CompetencyTagGroup(domain.Name, domain.Code); foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) { var tag = new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code)); tag.OnChanged = OnCompetencyToggled; group.Items.Add(tag); } if (group.Items.Count > 0) CompetencyTagGroups.Add(group); } } private void OnCompetencyToggled(string code, bool selected) { if (selected) { if (!_competencyCodes.Contains(code)) _competencyCodes.Add(code); } else _competencyCodes.Remove(code); OnPropertyChanged(nameof(CompetencySummary)); } [RelayCommand] private void ToggleCompetencyPanel() => IsCompetencyPanelOpen = !IsCompetencyPanelOpen; [RelayCommand] private void Save() { TitleError = ""; StartDateTextError = ""; EndDateTextError = ""; var valid = true; if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } DateOnly? startDate = null; if (!string.IsNullOrWhiteSpace(StartDateText)) { if (!DateOnly.TryParseExact(StartDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var s)) { StartDateTextError = "Format TT.MM.JJJJ."; valid = false; } else startDate = s; } DateOnly? endDate = null; if (!string.IsNullOrWhiteSpace(EndDateText)) { if (!DateOnly.TryParseExact(EndDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var e)) { EndDateTextError = "Format TT.MM.JJJJ."; valid = false; } else endDate = e; } if (valid && startDate is not null && endDate is not null && endDate < startDate) { EndDateTextError = "Ende darf nicht vor dem Start liegen."; valid = false; } if (!valid) return; Result = _editingUnit ?? new Unit { GroupId = _groupId }; Result.Title = Title.Trim(); Result.StartDate = startDate; Result.EndDate = endDate; Result.Status = UnitStatusDisplay.FromName(StatusName); Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); Result.Competencies = _competencyCodes; if (Result.Status == UnitStatus.Active) { // Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende // Einheiten werden nicht verworfen, sondern fachlich sauber abgeschlossen. foreach (var previous in _units.GetByGroup(_groupId) .Where(u => u.Status == UnitStatus.Active && u.Id != Result.Id)) { previous.Status = UnitStatus.Completed; _units.Save(previous); } } _units.Save(Result); } } // ── Dialog: Stunde anlegen / bearbeiten (4.2.2) — tabellarischer Verlaufsplan ──── public partial class LessonDialogViewModel : ObservableObject { private readonly ILessonRepository _lessons; 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 _newlyUploadedStorageIds = []; [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private int? _lessonNumber; [ObservableProperty] private string _topic = ""; [ObservableProperty] private string _startTimeText = ""; [ObservableProperty] private string _planningIdeas = ""; [ObservableProperty] private string _homework = ""; [ObservableProperty] private bool _homeworkChecked; [ObservableProperty] private bool _homeworkCheckDismissed; [ObservableProperty] private string _reflection = ""; [ObservableProperty] private string _statusName = LessonStatusDisplay.Options[0]; [ObservableProperty] private string _dateTextError = ""; [ObservableProperty] private string _topicError = ""; [ObservableProperty] private string _startTimeTextError = ""; [ObservableProperty] private string _totalDurationDisplay = "0 Minuten gesamt"; [ObservableProperty] private string _timeBudgetLabel = ""; [ObservableProperty] private string _timeBudgetColorHex = "#9E9E9E"; [ObservableProperty] private bool _hasTimeBudgetInfo; public string[] StatusOptions => LessonStatusDisplay.Options; /// Nur bei vorhandenem Hausaufgabentext sinnvoll — steuert die Sichtbarkeit von /// HomeworkChecked/HomeworkCheckDismissed im Dialog (4.5.4). public bool HasHomeworkText => !string.IsNullOrWhiteSpace(Homework); public string[] MaterialSuggestions { get; } public string[] ShorthandSuggestions { get; } public ObservableCollection Phases { get; } = []; // Anhänge (Material/Arbeitsblätter, Experiment-/Gefährdungsbeurteilungsdokumente). public ObservableCollection 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. public Func>? OnPickAlternativePath { get; set; } public Lesson? Result { get; private set; } public string DialogTitle => _editingLesson is null ? "Neue Stunde anlegen" : "Stunde bearbeiten"; public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern"; /// Gruppe + Fach der Einheit, zu der diese Stunde gehört — nicht editierbar, nur zur /// Einordnung. Ohne das war bei gleichnamigen Klassen in mehreren Fächern (mehrere /// Lerngruppen mit gleichem Namen) nicht erkennbar, welche Stunde man gerade bearbeitet /// (Nutzer-Feedback). public string GroupSubjectDisplay { get; } // Für die KI-Unterstützung mit Fokus auf genau diese Stunde (4.5.22) — nur für bereits // gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat // keine echte Id, auf die sich die KI beziehen könnte. public Guid UnitId => _unitId; public Guid GroupId => _groupId; public Lesson? EditingLesson => _editingLesson; public bool CanAiAssist => _editingLesson is not null; public bool IsNewLesson => _editingLesson is null; /// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt /// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt /// veralteten Feldwerte dieses Fensters werden NICHT mehr über die KI-Änderung gespeichert. public void MarkAppliedExternally(Lesson updated) => Result = updated; public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes, IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage, Guid unitId, Guid groupId, string groupName, string subjectName, List materialSuggestions, List shorthandHistorySuggestions, Lesson? editingLesson, DateOnly? suggestedDate = null, int? suggestedPeriod = null) { _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}"; MaterialSuggestions = [.. materialSuggestions]; // Vorschläge kommen sowohl aus dem gepflegten Kürzel-Katalog (Einstellungen) als auch aus // bereits in anderen Stunden dieser Gruppe frei getippten Kurzsymbolen — ein Kurzsymbol // muss nicht vorab im Katalog stehen, um beim nächsten Mal wieder vorgeschlagen zu werden. var codes = shorthandCodes.GetAll(); var catalogCodes = (codes.Count > 0 ? codes : DefaultShorthandCodes.All).Select(c => c.Code); ShorthandSuggestions = catalogCodes.Concat(shorthandHistorySuggestions) .Where(s => !string.IsNullOrWhiteSpace(s)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase) .ToArray(); if (editingLesson is not null) { DateText = editingLesson.Date.ToString("dd.MM.yyyy"); LessonNumber = editingLesson.LessonNumber; Topic = editingLesson.Topic; StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? ""; PlanningIdeas = editingLesson.PlanningIdeas ?? ""; Homework = editingLesson.Homework ?? ""; HomeworkChecked = editingLesson.HomeworkChecked; HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed; 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 { var (date, lessonNumber) = suggestedDate.HasValue ? (suggestedDate.Value, suggestedPeriod) : SuggestNextLesson(); DateText = date.ToString("dd.MM.yyyy"); LessonNumber = lessonNumber; StatusName = LessonStatusDisplay.ToName(LessonStatus.Draft); } RecomputeTimes(); } /// /// Terminvorschlag für eine neue Stunde (4.5.1): statt des sonst über den Feld-Default /// eingesetzten heutigen Datums (das an einem beliebigen Wochentag steht und die /// Doppelstunden-Erkennung in stillschweigend auf eine /// Einzelperiode zurückfallen lässt, wenn der Wochentag nicht zufällig passt) der nächste laut /// Stundenplan (4.3) für diese Gruppe passende Wochentag — ab der letzten Stunde dieser /// Einheit, oder ab heute, wenn noch keine Stunde in dieser Einheit existiert oder die letzte /// in der Vergangenheit liegt. Die Stundennummer wird passend dazu aus dem frühesten /// `TimetableSlot` dieses Wochentags vorbelegt (bei einer Doppelstunde also die erste Periode, /// dieselbe Ankerkonvention wie bei der Serienerzeugung, 4.2.5). Ohne Stundenplan-Einträge für /// die Gruppe (z.B. Klassenrat) bleibt es beim heutigen Datum ohne Stundenvorschlag. /// private (DateOnly Date, int? LessonNumber) SuggestNextLesson() { var slots = _timetableSlots.GetByGroup(_groupId); var today = DateOnly.FromDateTime(DateTime.Today); if (slots.Count == 0) return (today, null); var weekdays = slots.Select(s => s.Weekday).ToHashSet(); var lastLessonDate = _lessons.GetByUnit(_unitId).Select(l => l.Date) .DefaultIfEmpty(today.AddDays(-1)).Max(); var candidate = lastLessonDate >= today ? lastLessonDate.AddDays(1) : today; while (!weekdays.Contains(candidate.DayOfWeek)) candidate = candidate.AddDays(1); var lessonNumber = slots.Where(s => s.Weekday == candidate.DayOfWeek).Min(s => (int?)s.PeriodNumber); return (candidate, lessonNumber); } [RelayCommand] private void AddPhase() { AddPhaseInternal(null); RecomputeTimes(); } private void AddPhaseInternal(LessonPhaseStep? source) { var item = new PhaseStepEditItem { Name = source?.Name ?? "", DurationMinutes = source?.DurationMinutes ?? 5, Activity = source?.Activity ?? "", Material = source?.Material ?? "", Shorthand = source?.Shorthand ?? "", MaterialPrompt = source?.MaterialPrompt, }; item.OnChanged = RecomputeTimes; item.OnRemove = RemovePhase; item.OnMoveUp = MovePhaseUp; item.OnMoveDown = MovePhaseDown; item.OnAssignAlternativePath = AssignAlternativePath; if (source?.AlternativePathId is Guid pathId) item.SetAlternativePath(_alternativePaths.GetById(pathId)); Phases.Add(item); } private async Task AssignAlternativePath(PhaseStepEditItem item) { if (OnPickAlternativePath is null) { item.SetAlternativePath(null); return; } var picked = await OnPickAlternativePath(item.AlternativePathId); item.SetAlternativePath(picked); } private void RemovePhase(PhaseStepEditItem item) { Phases.Remove(item); RecomputeTimes(); } private void MovePhaseUp(PhaseStepEditItem item) { var idx = Phases.IndexOf(item); if (idx > 0) Phases.Move(idx, idx - 1); RecomputeTimes(); } private void MovePhaseDown(PhaseStepEditItem item) { var idx = Phases.IndexOf(item); if (idx >= 0 && idx < Phases.Count - 1) Phases.Move(idx, idx + 1); RecomputeTimes(); } partial void OnStartTimeTextChanged(string value) => RecomputeTimes(); /// Übernimmt beim Wählen der Stundennummer auch gleich den Beginn aus dem Stundenraster /// (Einstellungen), sofern noch keiner eingetragen ist — beim Laden einer vorhandenen Stunde /// wird das direkt danach vom tatsächlich gespeicherten StartTime überschrieben (auch /// wenn das "kein Beginn hinterlegt" bedeutet), ein bereits eingetippter Beginn bleibt unangetastet. partial void OnLessonNumberChanged(int? value) { if (value is int period && string.IsNullOrWhiteSpace(StartTimeText) && _periodSchedule.GetTimes(period) is { } times) StartTimeText = times.Start.ToString("HH:mm"); RecomputeTimes(); } partial void OnDateTextChanged(string value) => RecomputeTimes(); partial void OnHomeworkChanged(string value) => OnPropertyChanged(nameof(HasHomeworkText)); // "Kontrolliert" und "bewusst nicht kontrollieren" schließen sich gegenseitig aus — beides sind // "erledigt"-Zustände mit unterschiedlicher Bedeutung, nie gleichzeitig sinnvoll. partial void OnHomeworkCheckedChanged(bool value) { if (value) HomeworkCheckDismissed = false; } partial void OnHomeworkCheckDismissedChanged(bool value) { if (value) HomeworkChecked = false; } /// Dauer ist die primäre Eingabe je Phase; die Uhrzeit wird daraus nur zur Anzeige /// abgeleitet — kumulativ ab "Beginn", sofern gesetzt (sonst bleibt sie leer). private void RecomputeTimes() { var total = Phases.Sum(p => p.DurationMinutes); TotalDurationDisplay = $"{total} Minuten gesamt"; TimeOnly? cursor = null; if (!string.IsNullOrWhiteSpace(StartTimeText) && TimeOnly.TryParseExact(StartTimeText, "HH:mm", null, DateTimeStyles.None, out var start)) cursor = start; foreach (var p in Phases) { p.ComputedTimeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : ""; if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes); } RecomputeTimeBudget(total); } /// /// Vergleicht die geplante Gesamtdauer mit der laut Stundenraster (Einstellungen) tatsächlich /// verfügbaren Zeit — Doppelstunden werden erkannt, indem ab der eingetragenen Stundennummer /// so lange die jeweils nächste Periode addiert wird, wie der Stundenplan (4.3) für dieselbe /// Gruppe/denselben Wochentag auch dort einen Slot hat (siehe ). /// Ohne erkennbare Stunde/Datum oder ohne im Stundenraster hinterlegte Uhrzeiten bleibt die /// Rückmeldung schlicht ausgeblendet, statt eine erfundene Dauer vorzutäuschen. /// private void RecomputeTimeBudget(int plannedMinutes) { if (LessonNumber is not int startPeriod || !DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) { HasTimeBudgetInfo = false; return; } var weekday = date.DayOfWeek; var groupSlotsByPeriod = _timetableSlots.GetByGroup(_groupId) .Where(s => s.Weekday == weekday) .ToDictionary(s => s.PeriodNumber); var available = _periodSchedule.GetDurationMinutes(startPeriod); var period = startPeriod + 1; while (groupSlotsByPeriod.ContainsKey(period)) { available += _periodSchedule.GetDurationMinutes(period); period++; } if (available <= 0) { HasTimeBudgetInfo = false; return; } var utilizationPercent = (double)plannedMinutes / available * 100; TimeBudgetColorHex = TimeBudgetColor(utilizationPercent); TimeBudgetLabel = $"{plannedMinutes} von {available} Minuten geplant ({utilizationPercent:0}%)"; HasTimeBudgetInfo = true; } /// /// Farbskala für die Auslastung (geplante / verfügbare Minuten): 93–96 % gilt als guter /// Zielbereich (grün) — ein kleiner Puffer, da 100 % erfahrungsgemäß schon knapp ist. Darüber /// wird es zunehmend rötlich, deutlich über 100 % kräftig rot. Deutlich unter 93 % (zu viel /// Luft) ist bewusst neutral/blau statt rot gehalten — kein Fehler, nur "hier geht noch was". /// private static string TimeBudgetColor(double utilizationPercent) => utilizationPercent switch { < 70 => "#90A4AE", // Blaugrau: deutlich zu wenig geplant < 93 => "#FFC107", // Gelb: noch Luft nach oben <= 96 => "#43A047", // Grün: guter Zielbereich <= 100 => "#FB8C00", // Orange: knapp, kaum Puffer <= 115 => "#E64A19", // Rotorange: leicht überplant _ => "#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() { DateTextError = ""; TopicError = ""; StartTimeTextError = ""; var valid = true; if (string.IsNullOrWhiteSpace(Topic)) { TopicError = "Thema erforderlich."; valid = false; } DateOnly date = default; if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out date)) { DateTextError = "Format TT.MM.JJJJ."; valid = false; } TimeOnly? startTime = null; if (!string.IsNullOrWhiteSpace(StartTimeText)) { if (!TimeOnly.TryParseExact(StartTimeText, "HH:mm", null, DateTimeStyles.None, out var t)) { StartTimeTextError = "Format HH:MM."; valid = false; } else startTime = t; } if (!valid) return; Result = _editingLesson ?? new Lesson { UnitId = _unitId, GroupId = _groupId }; Result.UnitId = _unitId; Result.GroupId = _groupId; Result.Date = date; Result.LessonNumber = LessonNumber; Result.Topic = Topic.Trim(); Result.StartTime = startTime; Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim(); Result.Phases = Phases.Select(p => p.ToModel()).ToList(); Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim(); Result.HomeworkChecked = HomeworkChecked; 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(); } } // ── Zeile im Verlaufsplan-Editor (4.2.2) ────────────────────────────────────── public partial class PhaseStepEditItem : ObservableObject { private static readonly string[] AlternativePathPalette = ["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27"]; [ObservableProperty] private string _name = ""; [ObservableProperty] private int _durationMinutes = 5; [ObservableProperty] private string _activity = ""; [ObservableProperty] private string _material = ""; [ObservableProperty] private string _shorthand = ""; [ObservableProperty] private string _computedTimeDisplay = ""; /// Gespeicherter Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI beim /// letzten "Übernehmen" einen Medienvorschlag für diese Phase gemacht hatte. Steuert die /// Sichtbarkeit des Kopieren-Buttons im Verlaufsplan-Editor. [ObservableProperty] private string? _materialPrompt; public bool HasMaterialPrompt => !string.IsNullOrWhiteSpace(MaterialPrompt); partial void OnMaterialPromptChanged(string? value) => OnPropertyChanged(nameof(HasMaterialPrompt)); /// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog /// (); checked→unchecked entfernt die Zuordnung. /// Änderungen, die von selbst kommen, lösen das nicht erneut aus. [ObservableProperty] private bool _hasAlternativePath; [ObservableProperty] private string _alternativePathName = ""; [ObservableProperty] private string _alternativePathColorHex = "#9E9E9E"; private bool _suppressAlternativePathToggle; public Guid? AlternativePathId { get; private set; } public Action? OnChanged { get; set; } public Action? OnRemove { get; set; } public Action? OnMoveUp { get; set; } public Action? OnMoveDown { get; set; } public Func? OnAssignAlternativePath { get; set; } partial void OnDurationMinutesChanged(int value) => OnChanged?.Invoke(); partial void OnHasAlternativePathChanged(bool value) { if (_suppressAlternativePathToggle) return; if (value) _ = OnAssignAlternativePath?.Invoke(this); else SetAlternativePath(null); } /// Wird sowohl beim Laden einer bestehenden Zuordnung als auch nach dem Zuweisen-Dialog /// aufgerufen (auch mit null bei Abbruch/Entfernen) — setzt Id/Anzeigename/Farbe konsistent /// und unterdrückt dabei das erneute Öffnen des Dialogs über . public void SetAlternativePath(AlternativeLessonPath? path) { _suppressAlternativePathToggle = true; AlternativePathId = path?.Id; AlternativePathName = path?.Name ?? ""; AlternativePathColorHex = path is null ? "#9E9E9E" : ColorFor(path.Name); HasAlternativePath = path is not null; _suppressAlternativePathToggle = false; OnChanged?.Invoke(); } private static string ColorFor(string name) { var hash = 0; foreach (var c in name) hash = hash * 31 + c; return AlternativePathPalette[Math.Abs(hash) % AlternativePathPalette.Length]; } [RelayCommand] private void Remove() => OnRemove?.Invoke(this); [RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this); [RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this); public LessonPhaseStep ToModel() => new() { Name = Name.Trim(), DurationMinutes = DurationMinutes, Activity = Activity.Trim(), Material = Material.Trim(), Shorthand = Shorthand.Trim(), MaterialPrompt = MaterialPrompt, AlternativePathId = AlternativePathId, }; } // ── Dialog: Alternativen Ablauf zuweisen/anlegen (4.2.2 Nachtrag) ──────────── public partial class AlternativePathDialogViewModel : ObservableObject { private readonly IAlternativeLessonPathRepository _repo; [ObservableProperty] private AlternativeLessonPath? _selectedPath; [ObservableProperty] private string _newName = ""; [ObservableProperty] private string _newDescription = ""; [ObservableProperty] private string _newNameError = ""; [ObservableProperty] private string _selectionError = ""; public ObservableCollection Available { get; } = []; public AlternativeLessonPath? Result { get; private set; } public AlternativePathDialogViewModel(IAlternativeLessonPathRepository repo, Guid? currentId) { _repo = repo; foreach (var p in repo.GetAll()) Available.Add(p); if (currentId is Guid id) SelectedPath = Available.FirstOrDefault(p => p.Id == id); } [RelayCommand] private void CreateNew() { NewNameError = ""; if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; } var entry = new AlternativeLessonPath { Name = NewName.Trim(), Description = string.IsNullOrWhiteSpace(NewDescription) ? null : NewDescription.Trim(), }; try { _repo.Save(entry); } catch (InvalidOperationException ex) { NewNameError = ex.Message; return; } catch (ArgumentException ex) { NewNameError = ex.Message; return; } Available.Add(entry); SelectedPath = entry; NewName = ""; NewDescription = ""; } [RelayCommand] private void Confirm() { SelectionError = ""; if (SelectedPath is null) { SelectionError = "Bitte einen Ablauf auswählen oder neu anlegen."; return; } Result = SelectedPath; } } // ── Dialog: Stunde verschieben (4.2.4) ──────────────────────────────────────── public partial class MoveLessonDialogViewModel : ObservableObject { [ObservableProperty] private string _newDateText; [ObservableProperty] private int? _newPeriod; [ObservableProperty] private bool _shiftFollowingPlanned = true; [ObservableProperty] private bool _splitDoubleLesson; [ObservableProperty] private string _newDateTextError = ""; [ObservableProperty] private string _newPeriodError = ""; public string CurrentDateDisplay { get; } public string CurrentPeriodDisplay { get; } public bool CanSplitDoubleLesson { get; } public MoveLessonTarget? Result { get; private set; } public MoveLessonDialogViewModel(DateOnly currentDate, int? currentPeriod = null, bool canSplitDoubleLesson = false, bool splitByDefault = false) { CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy"); CurrentPeriodDisplay = currentPeriod is null ? "nicht festgelegt" : $"{currentPeriod}. Stunde"; _newDateText = currentDate.ToString("dd.MM.yyyy"); _newPeriod = currentPeriod; CanSplitDoubleLesson = canSplitDoubleLesson; _splitDoubleLesson = canSplitDoubleLesson && splitByDefault; } [RelayCommand] private void Save() { NewDateTextError = ""; NewPeriodError = ""; if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) { NewDateTextError = "Format TT.MM.JJJJ."; return; } if (NewPeriod is < 1 or > 20) { NewPeriodError = "Bitte eine Stundennummer zwischen 1 und 20 wählen."; return; } Result = new MoveLessonTarget(date, ShiftFollowingPlanned, NewPeriod, CanSplitDoubleLesson && SplitDoubleLesson); } } // ── Dialog: Stunde einer anderen Einheit zuweisen (Korrektur der Einheit) ──── public partial class ChangeLessonUnitDialogViewModel : ObservableObject { [ObservableProperty] private TimetableUnitOption? _selectedUnit; [ObservableProperty] private string _error = ""; public string CurrentUnitLabel { get; } public ObservableCollection Units { get; } = []; public bool HasUnits => Units.Count > 0; public Unit? Result { get; private set; } public ChangeLessonUnitDialogViewModel(IUnitRepository units, Guid groupId, Guid currentUnitId, string currentUnitTitle) { CurrentUnitLabel = currentUnitTitle; foreach (var unit in units.GetByGroup(groupId).Where(u => u.Id != currentUnitId) .OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2) .ThenByDescending(u => u.StartDate) .ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase)) Units.Add(new TimetableUnitOption(unit)); } [RelayCommand] private void Save() { Error = ""; if (SelectedUnit is null) { Error = "Bitte eine Ziel-Einheit auswählen."; return; } Result = SelectedUnit.Model; } } // ── Dialog: Vorhandene Stunde zum Verknüpfen auswählen (Stundenplan-FixIt) ──── /// /// Sucht Stunden, die zu einem Stundenplan-Termin gehören könnten, aber (z.B. durch JSON-Import /// oder KI-Übernahme ohne Stundenplan-Bezug) keine passende /// haben und deshalb von TimetableViewModel.FindLessonForSlot nicht gefunden werden. /// Als eigene, von Avalonia unabhängige Methode extrahiert, damit die Zuordnungslogik ohne Fenster /// testbar ist — Aufrufer ist LessonDialog.axaml.cs (Button "Vorhandene Stunde verknüpfen"). /// public static class LessonFixItSearch { public static List FindCandidates(ILessonRepository lessons, Guid groupId, DateOnly date) => [.. lessons.GetByGroupAndRange(groupId, date.AddDays(-14), date.AddDays(14)) .Where(l => l.LessonNumber is null || l.Date == date) .OrderBy(l => l.Date == date ? 0 : 1) .ThenBy(l => Math.Abs(l.Date.DayNumber - date.DayNumber))]; } public sealed class LessonLinkOption(Lesson lesson, string unitTitle) { public Lesson Model { get; } = lesson; public string Label { get; } = string.IsNullOrWhiteSpace(lesson.Topic) ? "(ohne Thema)" : lesson.Topic; public string Detail { get; } = $"{lesson.Date:dd.MM.yyyy} · {(lesson.LessonNumber is int n ? $"{n}. Stunde" : "keine Stundennummer")} · Einheit „{unitTitle}“"; } public partial class LinkExistingLessonDialogViewModel : ObservableObject { [ObservableProperty] private LessonLinkOption? _selectedOption; [ObservableProperty] private string _error = ""; public ObservableCollection Options { get; } = []; public Lesson? Result { get; private set; } public LinkExistingLessonDialogViewModel(List candidates, IUnitRepository units) { foreach (var lesson in candidates) Options.Add(new LessonLinkOption(lesson, units.GetById(lesson.UnitId)?.Title ?? "?")); SelectedOption = Options.FirstOrDefault(); } [RelayCommand] private void Save() { Error = ""; if (SelectedOption is null) { Error = "Bitte eine Stunde auswählen."; return; } Result = SelectedOption.Model; } } // ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ──────── public partial class GenerateLessonSeriesDialogViewModel : ObservableObject { private readonly ITimetableSlotRepository _slots; private readonly ILessonRepository _lessons; private readonly ISchoolHolidayRepository _schoolHolidays; private readonly PublicHolidayService _publicHolidays; private readonly SchoolCalendarSettingsService _calendarSettings; private readonly Guid _unitId; private readonly Guid _groupId; [ObservableProperty] private string _fromDateText; [ObservableProperty] private string _toDateText; [ObservableProperty] private string _dateError = ""; public LessonSeriesResult? Result { get; private set; } public GenerateLessonSeriesDialogViewModel(ITimetableSlotRepository slots, ILessonRepository lessons, ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, Guid unitId, Guid groupId, DateOnly? defaultFrom, DateOnly? defaultTo) { _slots = slots; _lessons = lessons; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _unitId = unitId; _groupId = groupId; _fromDateText = (defaultFrom ?? DateOnly.FromDateTime(DateTime.Today)).ToString("dd.MM.yyyy"); _toDateText = (defaultTo ?? DateOnly.FromDateTime(DateTime.Today).AddMonths(1)).ToString("dd.MM.yyyy"); } /// Legt für jeden Wochentag/Stunde, den die Gruppe laut Stundenplan (4.3) hat, im gewählten /// Zeitraum eine neue Lesson an. Schulferien/Feiertage werden übersprungen (dieselbe Prüfung /// wie im Stundenplan-Wochenraster, siehe TimetableViewModel.IsFreeDay); für ein Datum, an dem /// die Gruppe laut Stundenplan bereits eine Lesson hat (gleiches Datum + gleiche Stundennummer, /// unabhängig von der Einheit — ein Lehrer kann an einem Termin nur eine tatsächliche Stunde /// halten), wird nichts doppelt angelegt. Neue Stunden bekommen bewusst kein Thema — die /// sonst übliche "Thema erforderlich"-Regel des manuellen "+Stunde"-Dialogs gilt hier nicht, /// da diese Platzhalter zum späteren Ausfüllen gedacht sind. /// /// Doppelstunden (zwei direkt aufeinanderfolgende Perioden desselben Wochentags/derselben /// Gruppe im Stundenplan — dieselbe Konvention wie bei /// und den Ferien-Badges in TimetableViewModel) bekommen bewusst nur EINE Lesson, verankert an /// der ersten Periode: eine Folgeperiode, deren Vorgängerperiode ebenfalls im Stundenplan steht, /// wird übersprungen, statt eine zweite Lesson mit gleichem Datum anzulegen. [RelayCommand] private void Save() { DateError = ""; if (!DateOnly.TryParseExact(FromDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) || !DateOnly.TryParseExact(ToDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to)) { DateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; return; } if (to < from) { DateError = "Das Ende darf nicht vor dem Beginn liegen."; return; } var slotsForGroup = _slots.GetByGroup(_groupId); if (slotsForGroup.Count == 0) { DateError = "Für diese Gruppe ist noch keine Stunde im Stundenplan eingetragen."; return; } var periodsByWeekday = slotsForGroup.GroupBy(s => s.Weekday) .ToDictionary(g => g.Key, g => g.Select(s => s.PeriodNumber).ToHashSet()); var schoolHolidays = _schoolHolidays.GetAll(); var publicHolidayDates = new HashSet(); for (var year = from.Year; year <= to.Year; year++) foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date); var existing = _lessons.GetByGroupAndRange(_groupId, from, to) .Select(l => (l.Date, l.LessonNumber)).ToHashSet(); int created = 0, skippedHoliday = 0, skippedExisting = 0; for (var date = from; date <= to; date = date.AddDays(1)) { var isFreeDay = publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate); foreach (var slot in slotsForGroup.Where(s => s.Weekday == date.DayOfWeek)) { // Zweite (und weitere) Periode einer Doppelstunde: gehört zur Lesson der ersten // Periode, keine eigene Lesson. if (periodsByWeekday[slot.Weekday].Contains(slot.PeriodNumber - 1)) continue; if (isFreeDay) { skippedHoliday++; continue; } if (existing.Contains((date, (int?)slot.PeriodNumber))) { skippedExisting++; continue; } _lessons.Save(new Lesson { UnitId = _unitId, GroupId = _groupId, Date = date, LessonNumber = slot.PeriodNumber, Topic = "", }); created++; } } Result = new LessonSeriesResult(created, skippedHoliday, skippedExisting); } } // ── Dialog: KI-gestützte Planungsunterstützung (4.5.9) ─────────────────────── /// Ein kopierbarer Prompt für ein von der KI vorgeschlagenes Material/Medium zu einer Phase (siehe /// AiPlanningService.BuildMaterialPrompt) — gedacht zum Einfügen in eine separate Claude-Sitzung, /// nicht über das eigene KI-Backend erzeugt. public record MaterialPromptItem(string PhaseName, string SuggestionText, string PromptText); /// Eine von der KI vorgeschlagene Stunde in der Prüfliste des Dialogs — angehakt = wird beim /// "Übernehmen" mit übertragen. unterscheidet neu/geändert (siehe /// AiPlanningDtos.cs), steuert hier nur die Anzeige ("Neu"/"Geändert"). public partial class AiLessonReviewItem : ObservableObject { public AiLesson Source { get; } public bool IsNew { get; } public string DisplayLabel { get; } /// Feld-Diff gegenüber der bestehenden Lesson (4.5.14 Planungsdiff), als fertig formatierte /// Aufzählung fürs UI — leer bei neuen Vorschlägen, da es dort nichts zu vergleichen gibt. public string DiffText { get; } /// Medienvorschläge je Phase dieser Lesson (nur Phasen mit gesetztem /// AiPhaseStep.MaterialSuggestion) — leer, wenn die KI für keine Phase einen Vorschlag hatte. public IReadOnlyList MaterialPrompts { get; } /// Ob für diese Lesson überhaupt ein "Hintergrund erklären"-Button angeboten wird (4.5.21) — /// false z.B. in Tests/Kontexten ohne verdrahtete Abfragefunktion. public bool CanRequestExplanation => _requestExplanation is not null; /// Button verschwindet, sobald der Hintergrund einmal geladen wurde (Text steht dann da statt /// des Buttons) — kein Grund, dieselbe kostenpflichtige Anfrage zweimal anzubieten. public bool ShowExplanationButton => CanRequestExplanation && string.IsNullOrEmpty(Explanation); partial void OnExplanationChanged(string value) => OnPropertyChanged(nameof(ShowExplanationButton)); private readonly Func>? _requestExplanation; [ObservableProperty] private bool _accepted = true; [ObservableProperty] private string _explanation = ""; [ObservableProperty] private bool _isLoadingExplanation; [ObservableProperty] private string _explanationError = ""; public AiLessonReviewItem(AiLesson source, bool isNew, List? fieldDiffs = null, List? materialPrompts = null, Func>? requestExplanation = null) { Source = source; IsNew = isNew; var dateText = source.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "kein Datum"; DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})"; DiffText = fieldDiffs is { Count: > 0 } ? string.Join("\n", fieldDiffs.Select(d => "• " + d)) : ""; MaterialPrompts = materialPrompts ?? []; _requestExplanation = requestExplanation; } /// Holt den didaktischen Hintergrund erst auf Klick nach (4.5.21 "Schattenfeld") — eigener, /// nur bei tatsächlicher Nutzung abgerechneter Endpunkt statt bei jeder Antwort mitgeneriert. [RelayCommand] private async Task RequestExplanation() { if (_requestExplanation is null || IsLoadingExplanation) return; IsLoadingExplanation = true; ExplanationError = ""; try { Explanation = await _requestExplanation(Source); } catch (AiBackendException ex) { ExplanationError = ex.Message; } finally { IsLoadingExplanation = false; } } } public partial class AiAssistDialogViewModel : ObservableObject { private readonly AiPlanningService _aiPlanning; private readonly AiSettingsService _aiSettings; private readonly ILessonRepository _lessons; private readonly Unit _unit; private readonly Lesson? _focusLesson; [ObservableProperty] private string _instruction = ""; [ObservableProperty] private bool _allowModifyingExisting = true; [ObservableProperty] private bool _isBusy; [ObservableProperty] private string _errorMessage = ""; [ObservableProperty] private bool _hasResults; [ObservableProperty] private string? _summary; [ObservableProperty] private string? _rawResponse; public string UnitSummary { get; } public ObservableCollection ReviewItems { get; } = []; public bool Result { get; private set; } public Unit Unit => _unit; public Guid? FocusLessonId => _focusLesson?.Id; public bool CanRescueResponse => !string.IsNullOrWhiteSpace(RawResponse); partial void OnRawResponseChanged(string? value) => OnPropertyChanged(nameof(CanRescueResponse)); /// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht, /// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine /// Stunde bearbeiten, der Umfangs-Umschalter macht in diesem Modus keinen Sinn und wird /// ausgeblendet (siehe AiAssistDialog.axaml). public bool IsFocusMode => _focusLesson is not null; public string? FocusLabel => _focusLesson is null ? null : $"Fokus: nur diese Stunde — „{_focusLesson.Topic}“ ({_focusLesson.Date:dd.MM.yyyy})"; public AiAssistDialogViewModel(AiPlanningService aiPlanning, AiSettingsService aiSettings, ILessonRepository lessons, Unit unit, Lesson? focusLesson = null) { _aiPlanning = aiPlanning; _aiSettings = aiSettings; _lessons = lessons; _unit = unit; _focusLesson = focusLesson; // Ohne Änderungserlaubnis gäbe es im Fokus-Modus nichts, was die KI überhaupt vorschlagen // dürfte (neue Stunden sind hier ja ausdrücklich nicht das Ziel) — deshalb erzwungen an. if (IsFocusMode) AllowModifyingExisting = true; var lessonCount = lessons.GetByUnit(unit.Id).Count; UnitSummary = $"Einheit: {unit.Title} — {lessonCount} Stunde(n)"; } [RelayCommand] private async Task Send() { var token = _aiSettings.GetToken(); if (token is null) { ErrorMessage = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden."; return; } // Nachfassen (HasResults bereits true): die aktuell angehakten Vorschläge der letzten Runde // als Entwurf mitschicken, damit die KI auf dem bereits gezeigten, noch nicht gespeicherten // Stand aufbaut statt nur auf dem tatsächlichen Datenbankstand der Einheit. var draftOverrides = HasResults ? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList() : null; ErrorMessage = ""; RawResponse = null; IsBusy = true; try { var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, draftOverrides, _focusLesson?.Id); var existingLessons = _lessons.GetByUnit(_unit.Id).ToDictionary(l => l.Id); // Im Fokus-Modus hart auf die eine angefragte Stunde beschränken, statt der KI-Antwort // zu vertrauen — dieselbe Absicherung wie beim Umfangs-Umschalter unten. var lessonsToShow = IsFocusMode ? response.Lessons.Where(l => l.Id == _focusLesson!.Id).ToList() : response.Lessons; ReviewItems.Clear(); foreach (var l in lessonsToShow) { var isExisting = l.Id is { } id && existingLessons.ContainsKey(id); // Falls der Modus Änderungen an bestehenden Stunden verbietet, aber die KI die // Anweisung trotzdem ignoriert hat: gar nicht erst zur Übernahme anbieten, statt // dem Nutzer eine Auswahl zu zeigen, die ApplyResponse ohnehin verwerfen würde. if (isExisting && !AllowModifyingExisting) continue; var fieldDiffs = isExisting ? _aiPlanning.DescribeChanges(existingLessons[l.Id!.Value], l) : null; var materialPrompts = l.Phases .Where(p => !string.IsNullOrWhiteSpace(p.MaterialSuggestion)) .Select(p => new MaterialPromptItem(p.Name, p.MaterialSuggestion!, _aiPlanning.BuildMaterialPrompt(_unit, l, p))) .ToList(); ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs, materialPrompts, requestExplanation: aiLesson => _aiPlanning.RequestExplanationAsync(_unit, aiLesson, token))); } Summary = response.Summary; HasResults = true; } catch (AiBackendException ex) { ErrorMessage = ex.Message; RawResponse = ex.RawResponse; } finally { IsBusy = false; } } [RelayCommand] private void Apply() { var accepted = ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList(); foreach (var lesson in _aiPlanning.ApplyResponse(_unit, accepted, AllowModifyingExisting, _focusLesson?.Id)) _lessons.Save(lesson); Result = true; } [RelayCommand] private void Cancel() => Result = false; public void MarkRescueImported() => Result = true; } /// Aus einer manuell geprüften KI-Antwort auswählbare Stunde. public record AiRescueLessonOption(AiLesson Lesson, string Label); /// Ziel für den manuellen Import in eine bereits vorhandene Stunde. public record AiRescueTargetOption(Lesson Lesson, string Label); /// /// Rettungsdialog für syntaktisch fehlerhafte oder mit Freitext vermischte Modellantworten. Der /// Nutzer entscheidet selbst, welcher Textabschnitt geparst und wohin die Stunde importiert wird. /// public partial class AiResponseRescueDialogViewModel : ObservableObject { private readonly AiPlanningService _aiPlanning; private readonly ILessonRepository _lessons; private readonly Unit _unit; [ObservableProperty] private string _responseText; [ObservableProperty] private string _parseMessage = "Markiere den gültigen JSON-Abschnitt oder bearbeite den Text und klicke auf „Markierung prüfen“."; [ObservableProperty] private AiRescueLessonOption? _selectedParsedLesson; [ObservableProperty] private AiRescueTargetOption? _selectedTarget; public ObservableCollection ParsedLessons { get; } = []; public ObservableCollection ExistingLessons { get; } = []; public bool HasParsedLesson => SelectedParsedLesson is not null; public bool CanImportIntoExisting => SelectedParsedLesson is not null && SelectedTarget is not null; public bool Result { get; private set; } partial void OnSelectedParsedLessonChanged(AiRescueLessonOption? value) { OnPropertyChanged(nameof(HasParsedLesson)); OnPropertyChanged(nameof(CanImportIntoExisting)); } partial void OnSelectedTargetChanged(AiRescueTargetOption? value) => OnPropertyChanged(nameof(CanImportIntoExisting)); public AiResponseRescueDialogViewModel(AiPlanningService aiPlanning, ILessonRepository lessons, Unit unit, string rawResponse, Guid? preferredTargetId = null) { _aiPlanning = aiPlanning; _lessons = lessons; _unit = unit; _responseText = rawResponse; foreach (var lesson in lessons.GetByUnit(unit.Id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber)) { var date = lesson.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); var number = lesson.LessonNumber is { } n ? $", Stunde {n}" : ""; ExistingLessons.Add(new AiRescueTargetOption(lesson, $"{date}{number}: {lesson.Topic}")); } SelectedTarget = ExistingLessons.FirstOrDefault(x => x.Lesson.Id == preferredTargetId) ?? ExistingLessons.FirstOrDefault(); } public void ParseSelection(string selectedText) { ParsedLessons.Clear(); SelectedParsedLesson = null; try { var parsed = AiPlanningService.ParsePlanningLessons(selectedText); foreach (var lesson in parsed) { var date = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "ohne Datum"; ParsedLessons.Add(new AiRescueLessonOption(lesson, $"{date}: {lesson.Topic}")); } SelectedParsedLesson = ParsedLessons[0]; ParseMessage = parsed.Count == 1 ? "Eine gültige Stunde erkannt." : $"{parsed.Count} gültige Stunden erkannt. Bitte die gewünschte Stunde auswählen."; } catch (AiBackendException ex) { ParseMessage = ex.Message; } } [RelayCommand] private void ImportIntoExisting() { if (SelectedParsedLesson is null || SelectedTarget is null) return; var lesson = CloneForImport(SelectedParsedLesson.Lesson, SelectedTarget.Lesson.Id); Save([lesson], focusLessonId: SelectedTarget.Lesson.Id); } [RelayCommand] private void ImportAsNew() { if (SelectedParsedLesson is null) return; Save([CloneForImport(SelectedParsedLesson.Lesson, null)]); } private void Save(List source, Guid? focusLessonId = null) { foreach (var lesson in _aiPlanning.ApplyResponse(_unit, source, allowModifyingExistingLessons: true, focusLessonId)) _lessons.Save(lesson); Result = true; } private static AiLesson CloneForImport(AiLesson source, Guid? id) => new() { Id = id, Date = source.Date, LessonNumber = source.LessonNumber, Topic = source.Topic, StartTime = source.StartTime, Homework = source.Homework, Reflection = source.Reflection, Phases = source.Phases.Select(p => new AiPhaseStep { Name = p.Name, DurationMinutes = p.DurationMinutes, Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, AlternativePathName = p.AlternativePathName, MaterialSuggestion = p.MaterialSuggestion, }).ToList(), }; } // ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ──────────── public partial class CopyUnitDialogViewModel : ObservableObject { [ObservableProperty] private LearningGroup? _selectedGroup; [ObservableProperty] private string _newStartDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _selectedGroupError = ""; [ObservableProperty] private string _newStartDateTextError = ""; public ObservableCollection AvailableGroups { get; } = []; public CopyUnitTarget? Result { get; private set; } public CopyUnitDialogViewModel(IGroupRepository groups, Guid excludeGroupId) { foreach (var g in groups.GetAll().Where(g => g.Id != excludeGroupId).OrderBy(g => g.Name)) AvailableGroups.Add(g); } [RelayCommand] private void Save() { SelectedGroupError = ""; NewStartDateTextError = ""; var valid = true; if (SelectedGroup is null) { SelectedGroupError = "Zielgruppe auswählen."; valid = false; } DateOnly anchor = default; if (!DateOnly.TryParseExact(NewStartDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out anchor)) { NewStartDateTextError = "Format TT.MM.JJJJ."; valid = false; } if (!valid) return; Result = new CopyUnitTarget(SelectedGroup!.Id, anchor); } } // ── Verlaufsplan-Ansicht (schreibgeschützt, für den Unterrichtseinsatz) ────── /// Eine Phasen-Zeile in der schreibgeschützten Ansicht — reine Anzeige, keine Bearbeitung. public record PhaseViewItem(string Name, int DurationMinutes, string TimeDisplay, string Activity, string Material, string Shorthand); /// Eine Gruppe von Phasen mit demselben /// ("Hauptweg" bei null). Jede Gruppe bekommt ihre eigene kumulierte Zeitberechnung ab /// Lesson.StartTime — eine Alternative zeigt also "so würde die Uhr laufen, wenn man diesen Weg /// von Stundenbeginn an nimmt", nicht ab einer gemeinsamen Verzweigungsstelle im Hauptweg. public record PhaseGroupViewItem(string Label, bool IsMainPath, string? Description, List Phases); public partial class LessonViewerViewModel : ObservableObject { private const string MainPathLabel = "Hauptweg"; public Guid GroupId { get; } public string DateDisplay { get; } public string Topic { get; } public string StatusLabel { get; } public string StartTimeDisplay { get; } public string? Homework { get; } public string? Reflection { get; } public List PhaseGroups { get; } public bool HasAlternatives { get; } /// Verzweigung aus dem Viewer heraus (4.5.3): Ziel-Tab-Index von GroupDetailView.axaml /// (3 Mitarbeit, 5 Noten) — das Code-Behind schließt den Viewer und navigiert dorthin. public Action? OnNavigateToTab { get; set; } [RelayCommand] private void NavigateToParticipation() => OnNavigateToTab?.Invoke(3); [RelayCommand] private void NavigateToGrades() => OnNavigateToTab?.Invoke(5); public LessonViewerViewModel(Lesson lesson, IAlternativeLessonPathRepository alternativePaths) { GroupId = lesson.GroupId; DateDisplay = lesson.Date.ToString("dd.MM.yyyy"); Topic = lesson.Topic; StatusLabel = LessonStatusDisplay.ToName(lesson.Status); StartTimeDisplay = lesson.StartTime?.ToString("HH:mm") ?? "–"; Homework = lesson.Homework; Reflection = lesson.Reflection; var order = new List(); var descriptions = new Dictionary(); var byLabel = new Dictionary>(); foreach (var p in lesson.Phases) { string label; string? description = null; if (p.AlternativePathId is Guid pathId) { var path = alternativePaths.GetById(pathId); label = path?.Name ?? "Unbekannter Ablauf"; description = path?.Description; } else label = MainPathLabel; if (!byLabel.TryGetValue(label, out var list)) { list = []; byLabel[label] = list; descriptions[label] = description; order.Add(label); } list.Add(p); } // Hauptweg immer zuerst, unabhängig davon, in welcher Reihenfolge Phasen angelegt wurden. var orderedLabels = order.OrderBy(l => l == MainPathLabel ? 0 : 1).ToList(); PhaseGroups = orderedLabels .Select(label => new PhaseGroupViewItem( label, label == MainPathLabel, descriptions[label], BuildPhaseViewItems(byLabel[label], lesson.StartTime))) .ToList(); HasAlternatives = PhaseGroups.Count > 1; } private static List BuildPhaseViewItems(List steps, TimeOnly? startTime) { var cursor = startTime; var items = new List(); foreach (var p in steps) { var timeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : ""; items.Add(new PhaseViewItem(p.Name, p.DurationMinutes, timeDisplay, p.Activity, p.Material, p.Shorthand)); if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes); } return items; } }