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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user