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 ICompetencyCatalogVersionRepository _competencyVersions;
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; } = "";
/// Aufgelöste Bildungsplan-Version dieser Gruppe (Gruppenwahl, sonst Standardversion des
/// Fachs) - ohne Fach.
public Guid CompetencyCatalogVersionId { 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, ICompetencyCatalogVersionRepository competencyVersions,
AiSettingsService aiSettings, IParticipationSessionRepository participationSessions)
{
_units = units; _lessons = lessons; _groups = groups;
_subjects = subjects; _competencyDomains = competencyDomains;
_competencyVersions = competencyVersions;
_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 ?? "";
CompetencyCatalogVersionId = CompetencyCatalogVersionResolver.Resolve(
_competencyVersions, SubjectId, group?.CompetencyCatalogVersionId);
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 Guid _competencyCatalogVersionId;
private readonly Unit? _editingUnit;
private readonly List _competencyRefs;
[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 => _competencyRefs.Count == 0
? "Keine Kompetenzen" : $"{_competencyRefs.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,
Guid competencyCatalogVersionId = default)
{
_units = units; _competencyDomains = competencyDomains;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_competencyCatalogVersionId = competencyCatalogVersionId;
_editingUnit = editingUnit;
GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}";
_competencyRefs = 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 catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId);
foreach (var group in CompetencyTagGroupBuilder.Build(catalog, _competencyRefs, OnCompetencyToggled))
CompetencyTagGroups.Add(group);
}
private void OnCompetencyToggled(CompetencyTag tag)
{
if (tag.IsSelected)
{
if (!_competencyRefs.Any(r => r.ItemId == tag.ItemId))
_competencyRefs.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description });
}
else
{
_competencyRefs.RemoveAll(r => r.ItemId == tag.ItemId);
}
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 = _competencyRefs;
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.
// Persist a pending parent only after validation and before writing its first lesson.
public Action? BeforeSave { get; set; }
public void EnsureParentSaved() => BeforeSave?.Invoke();
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;
EnsureParentSaved();
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;
}
}