Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
T
adminandClaude Sonnet 5 de6ea001e7 Unterrichtsplanung: Einheiten, Verlaufsplan-Editor, Kürzel-Katalog (Kapitel 4.1/4.2)
Neuer Tab "Planung" in GroupDetailView ersetzt den Platzhalter: Einheiten anlegen/bearbeiten/
als Vorlage in andere Gruppe kopieren, Stunden je Einheit mit Verschieben (inkl. Nachrücken
der Folgestunden). Stundeneditor als tabellarischer Verlaufsplan (Phase/Dauer/Tätigkeit/
Material/Kurzsymbol je Zeile, Uhrzeit aus optionalem Stundenbeginn abgeleitet) statt eines
einzelnen Phase-Felds mit Methoden-/Materialien-Chips — Kurzsymbol als Freitext mit
Vorschlägen aus neuem Kürzel-Katalog (Einstellungen) plus bisher verwendeten Werten.
Schema-Migrationen v1-v3 überführen bestehende Daten verlustfrei. 4.2.5 bewusst offen
gelassen (hängt an Stundenplan, Kapitel 4.3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 00:50:57 +02:00

797 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.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<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).
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 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<string>();
var shorthands = new HashSet<string>();
foreach (var unit in _units.GetByGroup(_groupId))
{
var unitLessons = _lessons.GetByUnit(unit.Id);
foreach (var l in unitLessons)
foreach (var p in l.Phases)
{
if (!string.IsNullOrWhiteSpace(p.Material)) materials.Add(p.Material);
if (!string.IsNullOrWhiteSpace(p.Shorthand)) shorthands.Add(p.Shorthand);
}
Units.Add(new UnitSummary(unit, unitLessons));
}
KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList();
KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList();
SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault();
}
partial void OnSelectedUnitChanged(UnitSummary? value)
{
LoadLessons();
OnPropertyChanged(nameof(SelectedUnitTitleSuffix));
EditUnitCommand.NotifyCanExecuteChanged();
DeleteUnitCommand.NotifyCanExecuteChanged();
CopyUnitCommand.NotifyCanExecuteChanged();
AddLessonCommand.NotifyCanExecuteChanged();
}
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)
{
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<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,
})],
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();
}
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private async Task DeleteLesson()
{
if (OnConfirmDeleteLesson is null || SelectedLesson is null) return;
var lesson = SelectedLesson;
if (!await OnConfirmDeleteLesson(lesson)) return;
_lessons.Delete(lesson.Id);
LoadUnits();
}
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private async Task MoveLesson()
{
if (OnPickMoveTarget is null || SelectedLesson is null) return;
var lesson = SelectedLesson.Model;
var target = await OnPickMoveTarget(lesson);
if (target is null) return;
MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing);
LoadUnits();
}
/// Verschiebt eine Stunde auf ein neues Datum (4.2.4). Ist "Nachrücken" aktiv, verschieben
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der
/// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing)
{
var oldDate = moved.Date;
var delta = newDate.DayNumber - oldDate.DayNumber;
if (shiftFollowing && delta != 0)
{
foreach (var other in _lessons.GetByUnit(moved.UnitId))
{
if (other.Id == moved.Id) continue;
if (other.Status != LessonStatus.Planned) continue;
if (other.Date <= oldDate) continue;
other.Date = other.Date.AddDays(delta);
_lessons.Save(other);
}
}
moved.Date = newDate;
_lessons.Save(moved);
}
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void AdvanceLessonStatus()
{
if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return;
var lesson = SelectedLesson.Model;
lesson.Status = LessonStatus.Conducted;
_lessons.Save(lesson);
LoadUnits();
}
}
// ── Anzeige-DTOs ──────────────────────────────────────────────────────────────
public class UnitSummary
{
public Guid Id { get; }
public Unit Model { get; }
public string Title { get; }
public string DateRangeDisplay { get; }
public UnitStatus Status { get; }
public string StatusLabel { get; }
public string StatusColorHex { get; }
public string CompetencyCountLabel { get; }
public int ConductedCount { get; }
public int TotalCount { get; }
public double ProgressFraction { get; }
public string ProgressText { get; }
public UnitSummary(Unit u, List<Lesson> lessons)
{
Id = u.Id;
Model = u;
Title = u.Title;
DateRangeDisplay = (u.StartDate, u.EndDate) switch
{
({ } start, { } end) => $"{start:dd.MM.yyyy} {end:dd.MM.yyyy}",
({ } start, null) => $"ab {start:dd.MM.yyyy}",
(null, { } end) => $"bis {end:dd.MM.yyyy}",
_ => "",
};
Status = u.Status;
StatusLabel = u.Status switch
{
UnitStatus.Planned => "Geplant",
UnitStatus.Active => "Laufend",
UnitStatus.Completed => "Abgeschlossen",
_ => "",
};
StatusColorHex = u.Status switch
{
UnitStatus.Planned => "#9E9E9E",
UnitStatus.Active => "#FB8C00",
UnitStatus.Completed => "#43A047",
_ => "#9E9E9E",
};
CompetencyCountLabel = u.Competencies.Count == 0 ? "" : $"{u.Competencies.Count} Kompetenz(en)";
TotalCount = lessons.Count;
ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted);
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
}
}
public class LessonSummary
{
public Guid Id { get; }
public Lesson Model { get; }
public Guid UnitId { get; }
public string DateDisplay { get; }
public int? LessonNumber { get; }
public string Topic { get; }
public string StartTimeDisplay { get; }
public LessonStatus Status { get; }
public string StatusLabel { get; }
public string StatusColorHex { get; }
public string PhaseCountLabel { get; }
public string TotalDurationLabel { get; }
public string MaterialsDisplay { get; }
public bool HasHomework { get; }
public LessonSummary(Lesson l)
{
Id = l.Id;
Model = l;
UnitId = l.UnitId;
DateDisplay = l.Date.ToString("dd.MM.yyyy");
LessonNumber = l.LessonNumber;
Topic = l.Topic;
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "";
Status = l.Status;
StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E";
PhaseCountLabel = l.Phases.Count == 0 ? "" : $"{l.Phases.Count} Phasen";
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
TotalDurationLabel = totalMinutes == 0 ? "" : $"{totalMinutes} Min.";
MaterialsDisplay = string.Join(", ", l.Phases
.Select(p => p.Material)
.Where(m => !string.IsNullOrWhiteSpace(m))
.Distinct());
HasHomework = !string.IsNullOrWhiteSpace(l.Homework);
}
}
// ── Status-Anzeige (Einheiten/Stunden, analog NiveauDisplay) ──────────────────
public static class UnitStatusDisplay
{
public static string[] Options { get; } = ["Geplant", "Laufend", "Abgeschlossen"];
public static string ToName(UnitStatus s) => s switch
{
UnitStatus.Active => "Laufend",
UnitStatus.Completed => "Abgeschlossen",
_ => "Geplant",
};
public static UnitStatus FromName(string? name) => name switch
{
"Laufend" => UnitStatus.Active,
"Abgeschlossen" => UnitStatus.Completed,
_ => UnitStatus.Planned,
};
}
public static class LessonStatusDisplay
{
public static string[] Options { get; } = ["Geplant", "Durchgeführt"];
public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
public static LessonStatus FromName(string? name) =>
name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned;
}
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
public partial class UnitDialogViewModel : ObservableObject
{
private readonly IUnitRepository _units;
private readonly ICompetencyDomainRepository _competencyDomains;
private readonly Guid _groupId;
private readonly Guid? _subjectId;
private readonly int _gradeLevel;
private readonly Unit? _editingUnit;
private readonly List<string> _competencyCodes;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _startDateText = "";
[ObservableProperty] private string _endDateText = "";
[ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0];
[ObservableProperty] private string _notes = "";
[ObservableProperty] private bool _isCompetencyPanelOpen;
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _startDateTextError = "";
[ObservableProperty] private string _endDateTextError = "";
public string[] StatusOptions => UnitStatusDisplay.Options;
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
public string CompetencySummary => _competencyCodes.Count == 0
? "Keine Kompetenzen" : $"{_competencyCodes.Count} Kompetenz(en)";
/// Fach kommt von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein Fach).
public string SubjectDisplay { get; }
public Unit? Result { get; private set; }
public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten";
public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern";
public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains,
Guid groupId, Guid? subjectId, int gradeLevel, string subjectName, Unit? editingUnit)
{
_units = units; _competencyDomains = competencyDomains;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_editingUnit = editingUnit;
SubjectDisplay = string.IsNullOrWhiteSpace(subjectName)
? "Kein Fach hinterlegt (siehe Lerngruppe)" : $"Fach: {subjectName}";
_competencyCodes = editingUnit is not null ? [.. editingUnit.Competencies] : [];
BuildCompetencyTagGroups();
if (editingUnit is not null)
{
Title = editingUnit.Title;
StartDateText = editingUnit.StartDate?.ToString("dd.MM.yyyy") ?? "";
EndDateText = editingUnit.EndDate?.ToString("dd.MM.yyyy") ?? "";
StatusName = UnitStatusDisplay.ToName(editingUnit.Status);
Notes = editingUnit.Notes ?? "";
}
}
private void BuildCompetencyTagGroups()
{
CompetencyTagGroups.Clear();
if (!_subjectId.HasValue) return;
var selected = _competencyCodes.ToHashSet();
foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel))
{
var group = new CompetencyTagGroup(domain.Name, domain.Code);
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
{
var tag = new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code));
tag.OnChanged = OnCompetencyToggled;
group.Items.Add(tag);
}
if (group.Items.Count > 0) CompetencyTagGroups.Add(group);
}
}
private void OnCompetencyToggled(string code, bool selected)
{
if (selected) { if (!_competencyCodes.Contains(code)) _competencyCodes.Add(code); }
else _competencyCodes.Remove(code);
OnPropertyChanged(nameof(CompetencySummary));
}
[RelayCommand] private void ToggleCompetencyPanel() => IsCompetencyPanelOpen = !IsCompetencyPanelOpen;
[RelayCommand]
private void Save()
{
TitleError = ""; StartDateTextError = ""; EndDateTextError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
DateOnly? startDate = null;
if (!string.IsNullOrWhiteSpace(StartDateText))
{
if (!DateOnly.TryParseExact(StartDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var s))
{ StartDateTextError = "Format TT.MM.JJJJ."; valid = false; }
else startDate = s;
}
DateOnly? endDate = null;
if (!string.IsNullOrWhiteSpace(EndDateText))
{
if (!DateOnly.TryParseExact(EndDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var e))
{ EndDateTextError = "Format TT.MM.JJJJ."; valid = false; }
else endDate = e;
}
if (valid && startDate is not null && endDate is not null && endDate < startDate)
{ EndDateTextError = "Ende darf nicht vor dem Start liegen."; valid = false; }
if (!valid) return;
Result = _editingUnit ?? new Unit { GroupId = _groupId };
Result.Title = Title.Trim();
Result.StartDate = startDate;
Result.EndDate = endDate;
Result.Status = UnitStatusDisplay.FromName(StatusName);
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
Result.Competencies = _competencyCodes;
_units.Save(Result);
}
}
// ── Dialog: Stunde anlegen / bearbeiten (4.2.2) — tabellarischer Verlaufsplan ────
public partial class LessonDialogViewModel : ObservableObject
{
private readonly ILessonRepository _lessons;
private readonly 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<PhaseStepEditItem> Phases { get; } = [];
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)
{
_lessons = lessons; _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;
Phases.Add(item);
}
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
{
[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 = "";
public Action? OnChanged { get; set; }
public Action<PhaseStepEditItem>? OnRemove { get; set; }
public Action<PhaseStepEditItem>? OnMoveUp { get; set; }
public Action<PhaseStepEditItem>? OnMoveDown { get; set; }
partial void OnDurationMinutesChanged(int value) => OnChanged?.Invoke();
[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(),
};
}
// ── 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<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);
}
}