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>
This commit is contained in:
2026-08-14 00:50:57 +02:00
co-authored by Claude Sonnet 5
parent 27359794f7
commit de6ea001e7
28 changed files with 2372 additions and 32 deletions
@@ -183,6 +183,7 @@ public partial class GroupDetailViewModel : ObservableObject
public ParticipationTabViewModel ParticipationTab { get; }
public GradeOverviewTabViewModel GradeOverviewTab { get; }
public PlanningTabViewModel PlanningTab { get; }
public Func<Task<bool>>? OnAddStudent { get; set; }
public Func<Guid, Task<bool>>? OnAddExam { get; set; }
public Func<Exam, Task<bool>>? OnEditExam { get; set; }
@@ -194,12 +195,14 @@ public partial class GroupDetailViewModel : ObservableObject
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab)
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades;
ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab;
}
public void LoadGroup(Guid id)
@@ -217,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject
ReloadExams();
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle);
PlanningTab.Initialize(Group.Id);
}
private void ReloadExams()
@@ -0,0 +1,796 @@
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);
}
}
@@ -26,6 +26,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly PrivacySettingsService _privacy;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes;
// ── Fächer ────────────────────────────────────────────────────────────────
@@ -35,6 +36,14 @@ public partial class SettingsViewModel : ObservableObject
public ObservableCollection<SubjectListItem> Subjects { get; } = [];
// ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ──────────────────────────
[ObservableProperty] private string _newShorthandCode = "";
[ObservableProperty] private string _newShorthandLabel = "";
[ObservableProperty] private string _newShorthandCodeError = "";
public ObservableCollection<ShorthandCodeListItem> ShorthandCodes { get; } = [];
// ── Kompetenzkatalog ──────────────────────────────────────────────────────
[ObservableProperty] private SubjectListItem? _catalogSubject;
@@ -110,7 +119,8 @@ public partial class SettingsViewModel : ObservableObject
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
IDocumentationRepository documentation, IStudentRepository students)
IDocumentationRepository documentation, IStudentRepository students,
IShorthandCodeRepository shorthandCodes)
{
_subjects = subjects;
_domainRepo = domainRepo;
@@ -124,7 +134,9 @@ public partial class SettingsViewModel : ObservableObject
_privacy = privacy;
_documentation = documentation;
_students = students;
_shorthandCodes = shorthandCodes;
LoadSubjects();
LoadShorthandCodes();
LoadGradingKeyTemplates();
LoadGradingSchemes();
LoadBackups();
@@ -381,6 +393,40 @@ public partial class SettingsViewModel : ObservableObject
LoadSubjects();
}
// ── Kürzel-Katalog: Laden / Hinzufügen / Löschen ─────────────────────────
public void LoadShorthandCodes()
{
ShorthandCodes.Clear();
foreach (var c in _shorthandCodes.GetAll())
ShorthandCodes.Add(new ShorthandCodeListItem(c));
}
[RelayCommand]
private void AddShorthandCode()
{
if (string.IsNullOrWhiteSpace(NewShorthandCode)) { NewShorthandCodeError = "Kürzel erforderlich."; return; }
try
{
_shorthandCodes.Save(new ShorthandCode { Code = NewShorthandCode.Trim(), Label = NewShorthandLabel.Trim() });
}
catch (InvalidOperationException ex)
{
NewShorthandCodeError = ex.Message;
return;
}
NewShorthandCode = ""; NewShorthandLabel = ""; NewShorthandCodeError = "";
LoadShorthandCodes();
}
[RelayCommand]
private void DeleteShorthandCode(ShorthandCodeListItem? item)
{
if (item is null) return;
_shorthandCodes.Delete(item.Id);
LoadShorthandCodes();
}
// ── Katalog: Laden ────────────────────────────────────────────────────────
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
@@ -703,6 +749,13 @@ public class SubjectListItem(Subject s)
public string ShortName { get; } = s.ShortName;
}
public class ShorthandCodeListItem(ShorthandCode c)
{
public Guid Id { get; } = c.Id;
public string Code { get; } = c.Code;
public string Label { get; } = c.Label;
}
public class BackupListItem(BackupInfo info)
{
public string Path { get; } = info.Path;