Stundenverlaufsplan: Viewer und Alternativpfad-Katalog (Kapitel 4.2 Nachtrag)

Schreibgeschützter LessonViewerDialog (größere Schrift, ohne Bearbeitungs-/Verlängern-/
Verschieben-Funktion) für den Einsatz während des Unterrichtens, erreichbar über "Anzeigen"
im Stunden-Toolbar.

Alternative Unterrichtsabläufe (z.B. Kurzversion bei Zeitmangel) laufen jetzt über einen
echten Katalog (neues Modell AlternativeLessonPath: Name + Beschreibung) statt Freitext direkt
an der Phase: im Verlaufsplan-Editor eine kompakte, farbig unterstützte Checkbox statt einer
durchgehend sichtbaren Eingabespalte, Zuordnung/Neuanlage über einen eigenen Dialog. Der Viewer
gruppiert Phasen entsprechend und zeigt die hinterlegte Beschreibung. Schema-Migration v3→v4
führt bestehende Freitextwerte verlustfrei in Katalogeinträge über.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 01:53:12 +02:00
co-authored by Claude Sonnet 5
parent de6ea001e7
commit ee1a47641e
22 changed files with 904 additions and 33 deletions
@@ -41,7 +41,9 @@ public partial class PlanningTabViewModel : ObservableObject
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).
/// 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; } = [];
@@ -53,6 +55,7 @@ public partial class PlanningTabViewModel : ObservableObject
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 PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects,
@@ -120,6 +123,7 @@ public partial class PlanningTabViewModel : ObservableObject
partial void OnSelectedLessonChanged(LessonSummary? value)
{
ShowLessonCommand.NotifyCanExecuteChanged();
EditLessonCommand.NotifyCanExecuteChanged();
DeleteLessonCommand.NotifyCanExecuteChanged();
MoveLessonCommand.NotifyCanExecuteChanged();
@@ -209,6 +213,7 @@ public partial class PlanningTabViewModel : ObservableObject
Activity = p.Activity,
Material = p.Material,
Shorthand = p.Shorthand,
AlternativePathId = p.AlternativePathId,
})],
Homework = lesson.Homework,
Reflection = null,
@@ -233,6 +238,16 @@ public partial class PlanningTabViewModel : ObservableObject
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()
{
@@ -542,6 +557,7 @@ public partial class UnitDialogViewModel : ObservableObject
public partial class LessonDialogViewModel : ObservableObject
{
private readonly ILessonRepository _lessons;
private readonly IAlternativeLessonPathRepository _alternativePaths;
private readonly Guid _unitId;
private readonly Guid _groupId;
private readonly Lesson? _editingLesson;
@@ -563,15 +579,21 @@ public partial class LessonDialogViewModel : ObservableObject
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,
Guid unitId, Guid groupId, List<string> materialSuggestions, List<string> shorthandHistorySuggestions,
Lesson? editingLesson)
IAlternativeLessonPathRepository alternativePaths, Guid unitId, Guid groupId,
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
{
_lessons = lessons; _unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
_lessons = lessons; _alternativePaths = alternativePaths;
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
MaterialSuggestions = [.. materialSuggestions];
// Vorschläge kommen sowohl aus dem gepflegten Kürzel-Katalog (Einstellungen) als auch aus
@@ -620,9 +642,19 @@ public partial class LessonDialogViewModel : ObservableObject
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)
@@ -701,6 +733,9 @@ public partial class LessonDialogViewModel : ObservableObject
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 = "";
@@ -708,13 +743,53 @@ public partial class PhaseStepEditItem : ObservableObject
[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);
@@ -726,9 +801,61 @@ public partial class PhaseStepEditItem : ObservableObject
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
@@ -794,3 +921,84 @@ public partial class CopyUnitDialogViewModel : ObservableObject
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;
}
}