Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
T
admin 8495e1b8d0 KI-gestützte Planungsunterstützung (4.5.9) + Kompetenzkatalog-Import (8.1.2)
KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im
Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/)
sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf,
Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der
Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI
zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie
als Update übernommen, sondern immer als neue Stunde behandelt.

Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
2026-08-16 01:31:14 +02:00

1287 lines
54 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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);
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 Guid _groupId;
public Guid GroupId => _groupId;
public Guid? SubjectId { get; private set; }
public int GradeLevel { get; private set; }
public string SubjectName { 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 ObservableCollection<UnitSummary> Units { get; } = [];
public ObservableCollection<LessonSummary> 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<string> KnownMaterials { get; private set; } = [];
public List<string> KnownShorthands { get; private set; } = [];
public Func<Guid, Task<bool>>? OnAddUnit { get; set; }
public Func<Unit, Task<bool>>? OnEditUnit { get; set; }
public Func<UnitSummary, Task<bool>>? OnConfirmDeleteUnit { get; set; }
public Func<Unit, Task<CopyUnitTarget?>>? OnPickCopyTarget { get; set; }
public Func<Guid, Guid, List<string>, List<string>, Task<bool>>? OnAddLesson { get; set; }
public Func<Lesson, List<string>, List<string>, Task<bool>>? OnEditLesson { get; set; }
public Func<LessonSummary, Task<bool>>? OnConfirmDeleteLesson { get; set; }
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
public Func<Lesson, Task>? OnShowLesson { get; set; }
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects,
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings)
{
_units = units; _lessons = lessons; _groups = groups;
_subjects = subjects; _competencyDomains = competencyDomains;
_aiSettings = aiSettings;
}
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 ?? "" : "";
LoadUnits();
}
private void LoadUnits()
{
var selectedId = SelectedUnit?.Id;
Units.Clear();
var materials = new HashSet<string>();
var shorthands = new HashSet<string>();
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();
SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault();
}
partial void OnSelectedUnitChanged(UnitSummary? value)
{
LoadLessons();
OnPropertyChanged(nameof(SelectedUnitTitleSuffix));
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)
{
ShowLessonCommand.NotifyCanExecuteChanged();
EditLessonCommand.NotifyCanExecuteChanged();
DeleteLessonCommand.NotifyCanExecuteChanged();
MoveLessonCommand.NotifyCanExecuteChanged();
AdvanceLessonStatusCommand.NotifyCanExecuteChanged();
}
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<Lesson> 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 (<see cref="GenerateLessonSeriesDialogViewModel"/>), 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 (<see cref="AiAssistDialogViewModel"/>), 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;
MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing);
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)
{
var oldDate = moved.Date;
var delta = newDate.DayNumber - oldDate.DayNumber;
if (shiftFollowing && delta != 0)
{
foreach (var other in _lessons.GetByUnit(moved.UnitId))
{
if (other.Id == moved.Id) continue;
if (other.Status != LessonStatus.Planned) continue;
if (other.Date <= oldDate) continue;
other.Date = other.Date.AddDays(delta);
_lessons.Save(other);
}
}
moved.Date = newDate;
_lessons.Save(moved);
}
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void AdvanceLessonStatus()
{
if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return;
var lesson = SelectedLesson.Model;
lesson.Status = LessonStatus.Conducted;
_lessons.Save(lesson);
LoadUnits();
}
}
// ── 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<Lesson> 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)";
TotalCount = lessons.Count;
ConductedCount = lessons.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 = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#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; } = ["Geplant", "Durchgeführt"];
public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
public static LessonStatus FromName(string? name) =>
name == "Durchgeführt" ? LessonStatus.Conducted : 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<string> _competencyCodes;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _startDateText = "";
[ObservableProperty] private string _endDateText = "";
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
[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<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
public string CompetencySummary => _competencyCodes.Count == 0
? "Keine Kompetenzen" : $"{_competencyCodes.Count} Kompetenz(en)";
/// Fach kommt von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein Fach).
public string SubjectDisplay { 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";
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
Guid groupId, Guid? subjectId, int gradeLevel, string subjectName, Unit? editingUnit)
{
_units = units; _competencyDomains = competencyDomains;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_editingUnit = editingUnit;
SubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
? "Kein Fach hinterlegt (siehe Lerngruppe)" : $"Fach: {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;
_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 Guid _unitId;
private readonly Guid _groupId;
private readonly Lesson? _editingLesson;
[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 _homework = "";
[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;
public string[] MaterialSuggestions { get; }
public string[] ShorthandSuggestions { get; }
public ObservableCollection<PhaseStepEditItem> Phases { get; } = [];
/// 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<Guid?, Task<AlternativeLessonPath?>>? 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";
public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes,
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, Guid unitId, Guid groupId,
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
{
_lessons = lessons; _alternativePaths = alternativePaths;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
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") ?? "";
Homework = editingLesson.Homework ?? "";
Reflection = editingLesson.Reflection ?? "";
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
}
RecomputeTimes();
}
[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 ?? "",
};
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 <c>StartTime</c> ü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();
/// 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);
}
/// <summary>
/// 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 <see cref="TimetableSlot"/>).
/// Ohne erkennbare Stunde/Datum oder ohne im Stundenraster hinterlegte Uhrzeiten bleibt die
/// Rückmeldung schlicht ausgeblendet, statt eine erfundene Dauer vorzutäuschen.
/// </summary>
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;
}
/// <summary>
/// Farbskala für die Auslastung (geplante / verfügbare Minuten): 9396 % 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".
/// </summary>
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
};
[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.Phases = Phases.Select(p => p.ToModel()).ToList();
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim();
Result.Status = LessonStatusDisplay.FromName(StatusName);
_lessons.Save(Result);
}
}
// ── 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 = "";
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
/// Änderungen, die von <see cref="SetAlternativePath"/> 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<PhaseStepEditItem>? OnRemove { get; set; }
public Action<PhaseStepEditItem>? OnMoveUp { get; set; }
public Action<PhaseStepEditItem>? OnMoveDown { get; set; }
public Func<PhaseStepEditItem, Task>? 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 <see cref="OnHasAlternativePathChanged"/>.
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(),
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<AlternativeLessonPath> 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 bool _shiftFollowingPlanned = true;
[ObservableProperty] private string _newDateTextError = "";
public string CurrentDateDisplay { get; }
public MoveLessonTarget? Result { get; private set; }
public MoveLessonDialogViewModel(DateOnly currentDate)
{
CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
_newDateText = currentDate.ToString("dd.MM.yyyy");
}
[RelayCommand]
private void Save()
{
NewDateTextError = "";
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{
NewDateTextError = "Format TT.MM.JJJJ.";
return;
}
Result = new MoveLessonTarget(date, ShiftFollowingPlanned);
}
}
// ── 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.
[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 schoolHolidays = _schoolHolidays.GetAll();
var publicHolidayDates = new HashSet<DateOnly>();
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))
{
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) ───────────────────────
/// Eine von der KI vorgeschlagene Stunde in der Prüfliste des Dialogs — angehakt = wird beim
/// "Übernehmen" mit übertragen. <see cref="AiLesson.Id"/> unterscheidet neu/geändert (siehe
/// AiPlanningDtos.cs), <see cref="IsNew"/> 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; }
[ObservableProperty] private bool _accepted = true;
public AiLessonReviewItem(AiLesson source, bool isNew)
{
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})";
}
}
public partial class AiAssistDialogViewModel : ObservableObject
{
private readonly AiPlanningService _aiPlanning;
private readonly AiSettingsService _aiSettings;
private readonly ILessonRepository _lessons;
private readonly Unit _unit;
[ObservableProperty] private string _instruction = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _errorMessage = "";
[ObservableProperty] private bool _hasResults;
[ObservableProperty] private string? _summary;
public string UnitSummary { get; }
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
public bool Result { get; private set; }
public AiAssistDialogViewModel(AiPlanningService aiPlanning, AiSettingsService aiSettings,
ILessonRepository lessons, Unit unit)
{
_aiPlanning = aiPlanning; _aiSettings = aiSettings; _lessons = lessons; _unit = unit;
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;
}
ErrorMessage = ""; IsBusy = true;
try
{
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token);
var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet();
ReviewItems.Clear();
foreach (var l in response.Lessons)
ReviewItems.Add(new AiLessonReviewItem(l, isNew: l.Id is not { } id || !existingIds.Contains(id)));
Summary = response.Summary;
HasResults = true;
}
catch (AiBackendException ex) { ErrorMessage = ex.Message; }
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))
_lessons.Save(lesson);
Result = true;
}
[RelayCommand] private void Cancel() => Result = false;
}
// ── 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<LearningGroup> 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 <see cref="LessonPhaseStep.AlternativePathId"/>
/// ("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<PhaseViewItem> Phases);
public class LessonViewerViewModel
{
private const string MainPathLabel = "Hauptweg";
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<PhaseGroupViewItem> PhaseGroups { get; }
public bool HasAlternatives { get; }
public LessonViewerViewModel(Lesson lesson, IAlternativeLessonPathRepository alternativePaths)
{
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<string>();
var descriptions = new Dictionary<string, string?>();
var byLabel = new Dictionary<string, List<LessonPhaseStep>>();
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<PhaseViewItem> BuildPhaseViewItems(List<LessonPhaseStep> steps, TimeOnly? startTime)
{
var cursor = startTime;
var items = new List<PhaseViewItem>();
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;
}
}