using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using System.Collections.ObjectModel; using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Groups; // ── Ergebnisse der Verschieben-/Kopieren-Dialoge (4.2.4 / 4.1.4) ───────────── public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing); public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate); // ── 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 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; // 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 Units { get; } = []; public ObservableCollection Lessons { get; } = []; /// Aus den Material-/Kurzsymbol-Werten aller bereits vorhandenen Stunden-Phasen der Gruppe /// zusammengestellt (4.2.2 Autovervollständigung im Verlaufsplan-Editor). Alternative Abläufe /// kommen seit dem Katalog-Redesign nicht mehr aus der Stundenhistorie, sondern direkt aus /// IAlternativeLessonPathRepository (siehe LessonDialogViewModel). public List KnownMaterials { get; private set; } = []; public List KnownShorthands { get; private set; } = []; public Func>? OnAddUnit { get; set; } public Func>? OnEditUnit { get; set; } public Func>? OnConfirmDeleteUnit { get; set; } public Func>? OnPickCopyTarget { get; set; } public Func, List, Task>? OnAddLesson { get; set; } public Func, List, Task>? OnEditLesson { get; set; } public Func>? OnConfirmDeleteLesson { get; set; } public Func>? OnPickMoveTarget { get; set; } public Func? OnShowLesson { get; set; } public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, ISubjectRepository subjects, ICompetencyDomainRepository competencyDomains) { _units = units; _lessons = lessons; _groups = groups; _subjects = subjects; _competencyDomains = competencyDomains; } public void Initialize(Guid groupId) { _groupId = groupId; 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(); var shorthands = new HashSet(); foreach (var unit in _units.GetByGroup(_groupId)) { var unitLessons = _lessons.GetByUnit(unit.Id); foreach (var l in unitLessons) foreach (var p in l.Phases) { if (!string.IsNullOrWhiteSpace(p.Material)) materials.Add(p.Material); if (!string.IsNullOrWhiteSpace(p.Shorthand)) shorthands.Add(p.Shorthand); } Units.Add(new UnitSummary(unit, unitLessons)); } KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList(); KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList(); 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(); } 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; // ── Einheiten (4.1) ──────────────────────────────────────────────────────── [RelayCommand] private async Task AddUnit() { if (OnAddUnit is null) return; if (await OnAddUnit(_groupId)) LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task EditUnit() { if (OnEditUnit is null || SelectedUnit is null) return; if (await OnEditUnit(SelectedUnit.Model)) LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task DeleteUnit() { if (OnConfirmDeleteUnit is null || SelectedUnit is null) return; var unit = SelectedUnit; if (!await OnConfirmDeleteUnit(unit)) return; foreach (var l in _lessons.GetByUnit(unit.Id)) _lessons.Delete(l.Id); _units.Delete(unit.Id); LoadUnits(); } [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task CopyUnit() { if (OnPickCopyTarget is null || SelectedUnit is null) return; var unit = SelectedUnit.Model; var target = await OnPickCopyTarget(unit); if (target is null) return; CopyUnitAsTemplate(unit, _lessons.GetByUnit(unit.Id), target.TargetGroupId, target.AnchorDate); LoadUnits(); } /// Kopiert eine Einheit inkl. Stunden in eine andere Gruppe (4.1.4). "Ohne Datumsbezug" /// bedeutet: die relativen Tages-Abstände der Stunden zueinander bleiben erhalten, werden /// aber auf das neu gewählte Startdatum re-verankert statt die alten Kalendertage zu übernehmen. /// Reflexion wird geleert, Status auf "Geplant" zurückgesetzt. GroupId wird auf jeder neuen /// Lesson explizit auf die Zielgruppe gesetzt (siehe docs/Datenmodell.md). private void CopyUnitAsTemplate(Unit source, List sourceLessons, Guid targetGroupId, DateOnly anchorDate) { var earliest = sourceLessons.Count > 0 ? sourceLessons.Min(l => l.Date) : source.StartDate ?? anchorDate; var newUnit = new Unit { GroupId = targetGroupId, Title = source.Title, Competencies = [.. source.Competencies], Status = UnitStatus.Planned, Notes = source.Notes, StartDate = source.StartDate.HasValue ? anchorDate.AddDays(source.StartDate.Value.DayNumber - earliest.DayNumber) : null, EndDate = source.EndDate.HasValue ? anchorDate.AddDays(source.EndDate.Value.DayNumber - earliest.DayNumber) : null, }; _units.Save(newUnit); foreach (var lesson in sourceLessons) { _lessons.Save(new Lesson { UnitId = newUnit.Id, GroupId = targetGroupId, Date = anchorDate.AddDays(lesson.Date.DayNumber - earliest.DayNumber), LessonNumber = lesson.LessonNumber, Topic = lesson.Topic, StartTime = lesson.StartTime, Phases = [.. lesson.Phases.Select(p => new LessonPhaseStep { Name = p.Name, DurationMinutes = p.DurationMinutes, Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, AlternativePathId = p.AlternativePathId, })], Homework = lesson.Homework, Reflection = null, Status = LessonStatus.Planned, }); } } // ── Einzelstunden (4.2) ─────────────────────────────────────────────────── [RelayCommand(CanExecute = nameof(HasSelectedUnit))] private async Task AddLesson() { if (OnAddLesson is null || SelectedUnit is null) return; if (await OnAddLesson(SelectedUnit.Id, _groupId, KnownMaterials, KnownShorthands)) LoadUnits(); } [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 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 _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 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 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"; public string[] StatusOptions => LessonStatusDisplay.Options; public string[] MaterialSuggestions { get; } public string[] ShorthandSuggestions { get; } public ObservableCollection 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>? 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, Guid unitId, Guid groupId, List materialSuggestions, List shorthandHistorySuggestions, Lesson? 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 // 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(); /// 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); } } [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 /// (); checked→unchecked entfernt die Zuordnung. /// Änderungen, die von selbst kommen, lösen das nicht erneut aus. [ObservableProperty] private bool _hasAlternativePath; [ObservableProperty] private string _alternativePathName = ""; [ObservableProperty] private string _alternativePathColorHex = "#9E9E9E"; private bool _suppressAlternativePathToggle; public Guid? AlternativePathId { get; private set; } public Action? OnChanged { get; set; } public Action? OnRemove { get; set; } public Action? OnMoveUp { get; set; } public Action? OnMoveDown { get; set; } public Func? OnAssignAlternativePath { get; set; } partial void OnDurationMinutesChanged(int value) => OnChanged?.Invoke(); partial void OnHasAlternativePathChanged(bool value) { if (_suppressAlternativePathToggle) return; if (value) _ = OnAssignAlternativePath?.Invoke(this); else SetAlternativePath(null); } /// Wird sowohl beim Laden einer bestehenden Zuordnung als auch nach dem Zuweisen-Dialog /// aufgerufen (auch mit null bei Abbruch/Entfernen) — setzt Id/Anzeigename/Farbe konsistent /// und unterdrückt dabei das erneute Öffnen des Dialogs über . public void SetAlternativePath(AlternativeLessonPath? path) { _suppressAlternativePathToggle = true; AlternativePathId = path?.Id; AlternativePathName = path?.Name ?? ""; AlternativePathColorHex = path is null ? "#9E9E9E" : ColorFor(path.Name); HasAlternativePath = path is not null; _suppressAlternativePathToggle = false; OnChanged?.Invoke(); } private static string ColorFor(string name) { var hash = 0; foreach (var c in name) hash = hash * 31 + c; return AlternativePathPalette[Math.Abs(hash) % AlternativePathPalette.Length]; } [RelayCommand] private void Remove() => OnRemove?.Invoke(this); [RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this); [RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this); public LessonPhaseStep ToModel() => new() { Name = Name.Trim(), DurationMinutes = DurationMinutes, Activity = Activity.Trim(), Material = Material.Trim(), Shorthand = Shorthand.Trim(), AlternativePathId = AlternativePathId, }; } // ── Dialog: Alternativen Ablauf zuweisen/anlegen (4.2.2 Nachtrag) ──────────── public partial class AlternativePathDialogViewModel : ObservableObject { private readonly IAlternativeLessonPathRepository _repo; [ObservableProperty] private AlternativeLessonPath? _selectedPath; [ObservableProperty] private string _newName = ""; [ObservableProperty] private string _newDescription = ""; [ObservableProperty] private string _newNameError = ""; [ObservableProperty] private string _selectionError = ""; public ObservableCollection Available { get; } = []; public AlternativeLessonPath? Result { get; private set; } public AlternativePathDialogViewModel(IAlternativeLessonPathRepository repo, Guid? currentId) { _repo = repo; foreach (var p in repo.GetAll()) Available.Add(p); if (currentId is Guid id) SelectedPath = Available.FirstOrDefault(p => p.Id == id); } [RelayCommand] private void CreateNew() { NewNameError = ""; if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; } var entry = new AlternativeLessonPath { Name = NewName.Trim(), Description = string.IsNullOrWhiteSpace(NewDescription) ? null : NewDescription.Trim(), }; try { _repo.Save(entry); } catch (InvalidOperationException ex) { NewNameError = ex.Message; return; } catch (ArgumentException ex) { NewNameError = ex.Message; return; } Available.Add(entry); SelectedPath = entry; NewName = ""; NewDescription = ""; } [RelayCommand] private void Confirm() { SelectionError = ""; if (SelectedPath is null) { SelectionError = "Bitte einen Ablauf auswählen oder neu anlegen."; return; } Result = SelectedPath; } } // ── Dialog: Stunde verschieben (4.2.4) ──────────────────────────────────────── public partial class MoveLessonDialogViewModel : ObservableObject { [ObservableProperty] private string _newDateText; [ObservableProperty] private 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: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ──────────── public partial class CopyUnitDialogViewModel : ObservableObject { [ObservableProperty] private LearningGroup? _selectedGroup; [ObservableProperty] private string _newStartDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _selectedGroupError = ""; [ObservableProperty] private string _newStartDateTextError = ""; public ObservableCollection AvailableGroups { get; } = []; public CopyUnitTarget? Result { get; private set; } public CopyUnitDialogViewModel(IGroupRepository groups, Guid excludeGroupId) { foreach (var g in groups.GetAll().Where(g => g.Id != excludeGroupId).OrderBy(g => g.Name)) AvailableGroups.Add(g); } [RelayCommand] private void Save() { SelectedGroupError = ""; NewStartDateTextError = ""; var valid = true; if (SelectedGroup is null) { SelectedGroupError = "Zielgruppe auswählen."; valid = false; } DateOnly anchor = default; if (!DateOnly.TryParseExact(NewStartDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out anchor)) { NewStartDateTextError = "Format TT.MM.JJJJ."; valid = false; } if (!valid) return; Result = new CopyUnitTarget(SelectedGroup!.Id, anchor); } } // ── Verlaufsplan-Ansicht (schreibgeschützt, für den Unterrichtseinsatz) ────── /// Eine Phasen-Zeile in der schreibgeschützten Ansicht — reine Anzeige, keine Bearbeitung. public record PhaseViewItem(string Name, int DurationMinutes, string TimeDisplay, string Activity, string Material, string Shorthand); /// Eine Gruppe von Phasen mit demselben /// ("Hauptweg" bei null). Jede Gruppe bekommt ihre eigene kumulierte Zeitberechnung ab /// Lesson.StartTime — eine Alternative zeigt also "so würde die Uhr laufen, wenn man diesen Weg /// von Stundenbeginn an nimmt", nicht ab einer gemeinsamen Verzweigungsstelle im Hauptweg. public record PhaseGroupViewItem(string Label, bool IsMainPath, string? Description, List Phases); public 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 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(); var descriptions = new Dictionary(); var byLabel = new Dictionary>(); foreach (var p in lesson.Phases) { string label; string? description = null; if (p.AlternativePathId is Guid pathId) { var path = alternativePaths.GetById(pathId); label = path?.Name ?? "Unbekannter Ablauf"; description = path?.Description; } else label = MainPathLabel; if (!byLabel.TryGetValue(label, out var list)) { list = []; byLabel[label] = list; descriptions[label] = description; order.Add(label); } list.Add(p); } // Hauptweg immer zuerst, unabhängig davon, in welcher Reihenfolge Phasen angelegt wurden. var orderedLabels = order.OrderBy(l => l == MainPathLabel ? 0 : 1).ToList(); PhaseGroups = orderedLabels .Select(label => new PhaseGroupViewItem( label, label == MainPathLabel, descriptions[label], BuildPhaseViewItems(byLabel[label], lesson.StartTime))) .ToList(); HasAlternatives = PhaseGroups.Count > 1; } private static List BuildPhaseViewItems(List steps, TimeOnly? startTime) { var cursor = startTime; var items = new List(); foreach (var p in steps) { var timeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : ""; items.Add(new PhaseViewItem(p.Name, p.DurationMinutes, timeDisplay, p.Activity, p.Material, p.Shorthand)); if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes); } return items; } }