Kapitel 3.3: neuer Button erzeugt eine Mitarbeitssitzung aus einer geplanten Stunde (Datum/Thema, dupl.-sicher über Lesson.LessonId), Sitzungen lassen sich jetzt mit Rückfrage bei vorhandenen Bewertungen löschen, und der Dashboard-Kalender zeigt Sitzungen als dritten Termintyp neben Stunden/ Klausuren. Das Bearbeiten von Sitzungen war bereits vorhanden (undokumentiert aus früherer Ad-hoc-Arbeit) und wird hier nur nachgezogen. Bugfix: "Serie erzeugen" legte für Doppelstunden zwei Lessons mit gleichem Datum an statt einer, da jede Stundenplan-Periode einzeln behandelt wurde. Folgeperioden werden jetzt der Lesson der ersten Periode zugerechnet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1503 lines
67 KiB
C#
1503 lines
67 KiB
C#
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 readonly IParticipationSessionRepository _participationSessions;
|
||
|
||
private Guid _groupId;
|
||
|
||
public Guid GroupId => _groupId;
|
||
public Guid? SubjectId { get; private set; }
|
||
public int GradeLevel { get; private set; }
|
||
public string SubjectName { get; private set; } = "";
|
||
public string GroupLabel { get; private set; } = "";
|
||
|
||
[ObservableProperty] private UnitSummary? _selectedUnit;
|
||
[ObservableProperty] private LessonSummary? _selectedLesson;
|
||
[ObservableProperty] private bool _isReadOnly;
|
||
|
||
// Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen
|
||
// Gruppenwechsel/Laden kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal
|
||
// einen Binding-Fehler loggen (siehe GroupDetailViewModel.IsDifferentiated für dasselbe Muster).
|
||
public string SelectedUnitTitleSuffix => SelectedUnit is null ? "" : $" – {SelectedUnit.Title}";
|
||
public bool HasUnitSelection => SelectedUnit is not null;
|
||
public bool HasLessonSelection => SelectedLesson is not null;
|
||
public bool CanImportUnitPlanning => !IsReadOnly;
|
||
public bool CanImportLessonPlanning => !IsReadOnly && SelectedUnit is not null;
|
||
|
||
public ObservableCollection<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 Action<string>? OnNotify { get; set; }
|
||
|
||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||
IGroupRepository groups, ISubjectRepository subjects,
|
||
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings,
|
||
IParticipationSessionRepository participationSessions)
|
||
{
|
||
_units = units; _lessons = lessons; _groups = groups;
|
||
_subjects = subjects; _competencyDomains = competencyDomains;
|
||
_aiSettings = aiSettings; _participationSessions = participationSessions;
|
||
}
|
||
|
||
public void Initialize(Guid groupId, bool isReadOnly = false)
|
||
{
|
||
_groupId = groupId;
|
||
IsReadOnly = isReadOnly;
|
||
var group = _groups.GetById(groupId);
|
||
SubjectId = group?.SubjectId;
|
||
GradeLevel = group?.GradeLevel ?? 0;
|
||
SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : "";
|
||
GroupLabel = group?.Name ?? "";
|
||
LoadUnits();
|
||
}
|
||
|
||
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));
|
||
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();
|
||
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<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();
|
||
}
|
||
|
||
/// Ü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.
|
||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||
private void CreateParticipationSession()
|
||
{
|
||
if (SelectedLesson is null) return;
|
||
var lesson = SelectedLesson.Model;
|
||
var existing = _participationSessions.GetByGroup(lesson.GroupId)
|
||
.FirstOrDefault(s => s.LessonId == lesson.Id);
|
||
if (existing is not null)
|
||
{
|
||
OnNotify?.Invoke("Für diese Stunde 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<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)";
|
||
|
||
/// 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";
|
||
|
||
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
|
||
Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit)
|
||
{
|
||
_units = units; _competencyDomains = competencyDomains;
|
||
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
||
_editingUnit = editingUnit;
|
||
GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
|
||
? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}";
|
||
|
||
_competencyCodes = editingUnit is not null ? [.. editingUnit.Competencies] : [];
|
||
BuildCompetencyTagGroups();
|
||
|
||
if (editingUnit is not null)
|
||
{
|
||
Title = editingUnit.Title;
|
||
StartDateText = editingUnit.StartDate?.ToString("dd.MM.yyyy") ?? "";
|
||
EndDateText = editingUnit.EndDate?.ToString("dd.MM.yyyy") ?? "";
|
||
StatusName = UnitStatusDisplay.ToName(editingUnit.Status);
|
||
Notes = editingUnit.Notes ?? "";
|
||
}
|
||
}
|
||
|
||
private void BuildCompetencyTagGroups()
|
||
{
|
||
CompetencyTagGroups.Clear();
|
||
if (!_subjectId.HasValue) return;
|
||
var selected = _competencyCodes.ToHashSet();
|
||
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel))
|
||
{
|
||
var group = new CompetencyTagGroup(domain.Name, domain.Code);
|
||
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
|
||
{
|
||
var tag = new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code));
|
||
tag.OnChanged = OnCompetencyToggled;
|
||
group.Items.Add(tag);
|
||
}
|
||
if (group.Items.Count > 0) CompetencyTagGroups.Add(group);
|
||
}
|
||
}
|
||
|
||
private void OnCompetencyToggled(string code, bool selected)
|
||
{
|
||
if (selected) { if (!_competencyCodes.Contains(code)) _competencyCodes.Add(code); }
|
||
else _competencyCodes.Remove(code);
|
||
OnPropertyChanged(nameof(CompetencySummary));
|
||
}
|
||
|
||
[RelayCommand] private void ToggleCompetencyPanel() => IsCompetencyPanelOpen = !IsCompetencyPanelOpen;
|
||
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
TitleError = ""; StartDateTextError = ""; EndDateTextError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||
|
||
DateOnly? startDate = null;
|
||
if (!string.IsNullOrWhiteSpace(StartDateText))
|
||
{
|
||
if (!DateOnly.TryParseExact(StartDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var s))
|
||
{ StartDateTextError = "Format TT.MM.JJJJ."; valid = false; }
|
||
else startDate = s;
|
||
}
|
||
|
||
DateOnly? endDate = null;
|
||
if (!string.IsNullOrWhiteSpace(EndDateText))
|
||
{
|
||
if (!DateOnly.TryParseExact(EndDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var e))
|
||
{ EndDateTextError = "Format TT.MM.JJJJ."; valid = false; }
|
||
else endDate = e;
|
||
}
|
||
|
||
if (valid && startDate is not null && endDate is not null && endDate < startDate)
|
||
{ EndDateTextError = "Ende darf nicht vor dem Start liegen."; valid = false; }
|
||
|
||
if (!valid) return;
|
||
|
||
Result = _editingUnit ?? new Unit { GroupId = _groupId };
|
||
Result.Title = Title.Trim();
|
||
Result.StartDate = startDate;
|
||
Result.EndDate = endDate;
|
||
Result.Status = UnitStatusDisplay.FromName(StatusName);
|
||
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
|
||
Result.Competencies = _competencyCodes;
|
||
_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 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<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";
|
||
|
||
/// Gruppe + Fach der Einheit, zu der diese Stunde gehört — nicht editierbar, nur zur
|
||
/// Einordnung. Ohne das war bei gleichnamigen Klassen in mehreren Fächern (mehrere
|
||
/// Lerngruppen mit gleichem Namen) nicht erkennbar, welche Stunde man gerade bearbeitet
|
||
/// (Nutzer-Feedback).
|
||
public string GroupSubjectDisplay { get; }
|
||
|
||
// Für die KI-Unterstützung mit Fokus auf genau diese Stunde (4.5.22) — nur für bereits
|
||
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
|
||
// keine echte Id, auf die sich die KI beziehen könnte.
|
||
public Guid UnitId => _unitId;
|
||
public Lesson? EditingLesson => _editingLesson;
|
||
public bool CanAiAssist => _editingLesson is not 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, Guid unitId, Guid groupId, string groupName, string subjectName,
|
||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
||
{
|
||
_lessons = lessons; _alternativePaths = alternativePaths;
|
||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||
_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") ?? "";
|
||
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);
|
||
}
|
||
else
|
||
{
|
||
DateText = SuggestNextLessonDate().ToString("dd.MM.yyyy");
|
||
}
|
||
RecomputeTimes();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Vorbelegung für eine neue Stunde: statt des sonst über den Feld-Default eingesetzten
|
||
/// heutigen Datums (das an einem beliebigen Wochentag steht und die Doppelstunden-Erkennung
|
||
/// in <see cref="RecomputeTimeBudget"/> 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.
|
||
/// Ohne Stundenplan-Einträge für die Gruppe (z.B. Klassenrat) bleibt es beim heutigen Datum.
|
||
/// </summary>
|
||
private DateOnly SuggestNextLessonDate()
|
||
{
|
||
var weekdays = _timetableSlots.GetByGroup(_groupId).Select(s => s.Weekday).ToHashSet();
|
||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||
if (weekdays.Count == 0) return today;
|
||
|
||
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);
|
||
return candidate;
|
||
}
|
||
|
||
[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();
|
||
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);
|
||
}
|
||
|
||
/// <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): 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".
|
||
/// </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.HomeworkChecked = HomeworkChecked;
|
||
Result.HomeworkCheckDismissed = HomeworkCheckDismissed;
|
||
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.
|
||
///
|
||
/// Doppelstunden (zwei direkt aufeinanderfolgende Perioden desselben Wochentags/derselben
|
||
/// Gruppe im Stundenplan — dieselbe Konvention wie bei <see cref="LessonDialogViewModel.RecomputeTimeBudget"/>
|
||
/// 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<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))
|
||
{
|
||
// 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. <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; }
|
||
|
||
/// 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<MaterialPromptItem> 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<AiLesson, Task<string>>? _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<string>? fieldDiffs = null,
|
||
List<MaterialPromptItem>? materialPrompts = null, Func<AiLesson, Task<string>>? 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;
|
||
|
||
public string UnitSummary { get; }
|
||
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
|
||
public bool Result { get; private set; }
|
||
|
||
/// 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 = ""; 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; }
|
||
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;
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
}
|