1186 lines
50 KiB
C#
1186 lines
50 KiB
C#
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using LehrerApp.Core.Services;
|
||
using System.Collections.ObjectModel;
|
||
using System.Globalization;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||
|
||
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
|
||
|
||
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing);
|
||
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
||
|
||
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
|
||
{
|
||
public string Summary => $"{Created} Stunde(n) angelegt" +
|
||
(SkippedHoliday > 0 ? $", {SkippedHoliday} durch Ferien/Feiertage übersprungen" : "") +
|
||
(SkippedExisting > 0 ? $", {SkippedExisting} bereits vorhanden" : "") + ".";
|
||
}
|
||
|
||
// ── 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;
|
||
[ObservableProperty] private bool _isReadOnly;
|
||
|
||
// 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). Alternative Abläufe
|
||
/// kommen seit dem Katalog-Redesign nicht mehr aus der Stundenhistorie, sondern direkt aus
|
||
/// IAlternativeLessonPathRepository (siehe LessonDialogViewModel).
|
||
public List<string> KnownMaterials { get; private set; } = [];
|
||
public List<string> KnownShorthands { get; private set; } = [];
|
||
|
||
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 Func<Lesson, Task>? OnShowLesson { get; set; }
|
||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { 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, bool isReadOnly = false)
|
||
{
|
||
_groupId = groupId;
|
||
IsReadOnly = isReadOnly;
|
||
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();
|
||
GenerateLessonSeriesCommand.NotifyCanExecuteChanged();
|
||
}
|
||
|
||
private void LoadLessons()
|
||
{
|
||
var selectedId = SelectedLesson?.Id;
|
||
Lessons.Clear();
|
||
if (SelectedUnit is not null)
|
||
foreach (var l in _lessons.GetByUnit(SelectedUnit.Id))
|
||
Lessons.Add(new LessonSummary(l));
|
||
SelectedLesson = Lessons.FirstOrDefault(l => l.Id == selectedId);
|
||
}
|
||
|
||
partial void OnSelectedLessonChanged(LessonSummary? value)
|
||
{
|
||
ShowLessonCommand.NotifyCanExecuteChanged();
|
||
EditLessonCommand.NotifyCanExecuteChanged();
|
||
DeleteLessonCommand.NotifyCanExecuteChanged();
|
||
MoveLessonCommand.NotifyCanExecuteChanged();
|
||
AdvanceLessonStatusCommand.NotifyCanExecuteChanged();
|
||
}
|
||
|
||
private bool HasSelectedUnit() => SelectedUnit is not null;
|
||
private bool HasSelectedLesson() => SelectedLesson is not null;
|
||
|
||
// ── Einheiten (4.1) ────────────────────────────────────────────────────────
|
||
|
||
[RelayCommand]
|
||
private async Task AddUnit()
|
||
{
|
||
if (OnAddUnit is null) return;
|
||
if (await OnAddUnit(_groupId)) LoadUnits();
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedUnit))]
|
||
private async Task EditUnit()
|
||
{
|
||
if (OnEditUnit is null || SelectedUnit is null) return;
|
||
if (await OnEditUnit(SelectedUnit.Model)) LoadUnits();
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedUnit))]
|
||
private async Task DeleteUnit()
|
||
{
|
||
if (OnConfirmDeleteUnit is null || SelectedUnit is null) return;
|
||
var unit = SelectedUnit;
|
||
if (!await OnConfirmDeleteUnit(unit)) return;
|
||
foreach (var l in _lessons.GetByUnit(unit.Id)) _lessons.Delete(l.Id);
|
||
_units.Delete(unit.Id);
|
||
LoadUnits();
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedUnit))]
|
||
private async Task CopyUnit()
|
||
{
|
||
if (OnPickCopyTarget is null || SelectedUnit is null) return;
|
||
var unit = SelectedUnit.Model;
|
||
var target = await OnPickCopyTarget(unit);
|
||
if (target is null) return;
|
||
CopyUnitAsTemplate(unit, _lessons.GetByUnit(unit.Id), target.TargetGroupId, target.AnchorDate);
|
||
LoadUnits();
|
||
}
|
||
|
||
/// Kopiert eine Einheit inkl. Stunden in eine andere Gruppe (4.1.4). "Ohne Datumsbezug"
|
||
/// bedeutet: die relativen Tages-Abstände der Stunden zueinander bleiben erhalten, werden
|
||
/// aber auf das neu gewählte Startdatum re-verankert statt die alten Kalendertage zu übernehmen.
|
||
/// Reflexion wird geleert, Status auf "Geplant" zurückgesetzt. GroupId wird auf jeder neuen
|
||
/// Lesson explizit auf die Zielgruppe gesetzt (siehe docs/Datenmodell.md).
|
||
private void CopyUnitAsTemplate(Unit source, List<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,
|
||
AlternativePathId = p.AlternativePathId,
|
||
})],
|
||
Homework = lesson.Homework,
|
||
Reflection = null,
|
||
Status = LessonStatus.Planned,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Einzelstunden (4.2) ───────────────────────────────────────────────────
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedUnit))]
|
||
private async Task AddLesson()
|
||
{
|
||
if (OnAddLesson is null || SelectedUnit is null) return;
|
||
if (await OnAddLesson(SelectedUnit.Id, _groupId, KnownMaterials, KnownShorthands)) LoadUnits();
|
||
}
|
||
|
||
/// Serienerzeugung von Stunden aus dem Stundenplan (4.2.5) — die eigentliche Logik läuft im
|
||
/// Dialog (<see cref="GenerateLessonSeriesDialogViewModel"/>), hier wird nur nachgeladen.
|
||
[RelayCommand(CanExecute = nameof(HasSelectedUnit))]
|
||
private async Task GenerateLessonSeries()
|
||
{
|
||
if (OnGenerateLessonSeries is null || SelectedUnit is null) return;
|
||
var result = await OnGenerateLessonSeries(SelectedUnit.Model);
|
||
if (result is not null) LoadUnits();
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||
private async Task EditLesson()
|
||
{
|
||
if (OnEditLesson is null || SelectedLesson is null) return;
|
||
if (await OnEditLesson(SelectedLesson.Model, KnownMaterials, KnownShorthands)) LoadUnits();
|
||
}
|
||
|
||
/// Schreibgeschützte Anzeige des Verlaufsplans für den Einsatz im Unterricht (kein
|
||
/// Bearbeitungsrisiko). Bewusst ohne Live-Anpassung (Verlängern/Verschieben während des
|
||
/// Haltens) — das gehört zur zurückgestellten Live-Unterrichtsmodus-Ideensammlung (TODO.md).
|
||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||
private async Task ShowLesson()
|
||
{
|
||
if (OnShowLesson is null || SelectedLesson is null) return;
|
||
await OnShowLesson(SelectedLesson.Model);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||
private async Task DeleteLesson()
|
||
{
|
||
if (OnConfirmDeleteLesson is null || SelectedLesson is null) return;
|
||
var lesson = SelectedLesson;
|
||
if (!await OnConfirmDeleteLesson(lesson)) return;
|
||
_lessons.Delete(lesson.Id);
|
||
LoadUnits();
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||
private async Task MoveLesson()
|
||
{
|
||
if (OnPickMoveTarget is null || SelectedLesson is null) return;
|
||
var lesson = SelectedLesson.Model;
|
||
var target = await OnPickMoveTarget(lesson);
|
||
if (target is null) return;
|
||
MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing);
|
||
LoadUnits();
|
||
}
|
||
|
||
/// Verschiebt eine Stunde auf ein neues Datum (4.2.4). Ist "Nachrücken" aktiv, verschieben
|
||
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der
|
||
/// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
|
||
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
|
||
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing)
|
||
{
|
||
var oldDate = moved.Date;
|
||
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||
|
||
if (shiftFollowing && delta != 0)
|
||
{
|
||
foreach (var other in _lessons.GetByUnit(moved.UnitId))
|
||
{
|
||
if (other.Id == moved.Id) continue;
|
||
if (other.Status != LessonStatus.Planned) continue;
|
||
if (other.Date <= oldDate) continue;
|
||
other.Date = other.Date.AddDays(delta);
|
||
_lessons.Save(other);
|
||
}
|
||
}
|
||
|
||
moved.Date = newDate;
|
||
_lessons.Save(moved);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||
private void AdvanceLessonStatus()
|
||
{
|
||
if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return;
|
||
var lesson = SelectedLesson.Model;
|
||
lesson.Status = LessonStatus.Conducted;
|
||
_lessons.Save(lesson);
|
||
LoadUnits();
|
||
}
|
||
}
|
||
|
||
// ── Anzeige-DTOs ──────────────────────────────────────────────────────────────
|
||
|
||
public class UnitSummary
|
||
{
|
||
public Guid Id { get; }
|
||
public Unit Model { get; }
|
||
public string Title { get; }
|
||
public string DateRangeDisplay { get; }
|
||
public UnitStatus Status { get; }
|
||
public string StatusLabel { get; }
|
||
public string StatusColorHex { get; }
|
||
public string CompetencyCountLabel { get; }
|
||
public int ConductedCount { get; }
|
||
public int TotalCount { get; }
|
||
public double ProgressFraction { get; }
|
||
public string ProgressText { get; }
|
||
|
||
public UnitSummary(Unit u, List<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 IAlternativeLessonPathRepository _alternativePaths;
|
||
private readonly ITimetableSlotRepository _timetableSlots;
|
||
private readonly PeriodScheduleService _periodSchedule;
|
||
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";
|
||
[ObservableProperty] private string _timeBudgetLabel = "";
|
||
[ObservableProperty] private string _timeBudgetColorHex = "#9E9E9E";
|
||
[ObservableProperty] private bool _hasTimeBudgetInfo;
|
||
|
||
public string[] StatusOptions => LessonStatusDisplay.Options;
|
||
public string[] MaterialSuggestions { get; }
|
||
public string[] ShorthandSuggestions { get; }
|
||
public ObservableCollection<PhaseStepEditItem> Phases { get; } = [];
|
||
|
||
/// Vom Code-Behind gesetzt (Fenster als Owner für den Zuweisen-Dialog): fragt nach dem
|
||
/// alternativen Ablauf, dem eine Phase zugeordnet werden soll (Auswahl oder Neuanlage
|
||
/// per Combobox-Dialog). null zurückgegeben = abgebrochen.
|
||
public Func<Guid?, Task<AlternativeLessonPath?>>? OnPickAlternativePath { get; set; }
|
||
|
||
public Lesson? Result { get; private set; }
|
||
public string DialogTitle => _editingLesson is null ? "Neue Stunde anlegen" : "Stunde bearbeiten";
|
||
public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern";
|
||
|
||
public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes,
|
||
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
||
PeriodScheduleService periodSchedule, Guid unitId, Guid groupId,
|
||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
||
{
|
||
_lessons = lessons; _alternativePaths = alternativePaths;
|
||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||
_unitId = unitId; _groupId = groupId; _editingLesson = editingLesson;
|
||
MaterialSuggestions = [.. materialSuggestions];
|
||
|
||
// Vorschläge kommen sowohl aus dem gepflegten Kürzel-Katalog (Einstellungen) als auch aus
|
||
// bereits in anderen Stunden dieser Gruppe frei getippten Kurzsymbolen — ein Kurzsymbol
|
||
// muss nicht vorab im Katalog stehen, um beim nächsten Mal wieder vorgeschlagen zu werden.
|
||
var codes = shorthandCodes.GetAll();
|
||
var catalogCodes = (codes.Count > 0 ? codes : DefaultShorthandCodes.All).Select(c => c.Code);
|
||
ShorthandSuggestions = catalogCodes.Concat(shorthandHistorySuggestions)
|
||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase)
|
||
.ToArray();
|
||
|
||
if (editingLesson is not null)
|
||
{
|
||
DateText = editingLesson.Date.ToString("dd.MM.yyyy");
|
||
LessonNumber = editingLesson.LessonNumber;
|
||
Topic = editingLesson.Topic;
|
||
StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? "";
|
||
Homework = editingLesson.Homework ?? "";
|
||
Reflection = editingLesson.Reflection ?? "";
|
||
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
|
||
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
|
||
}
|
||
RecomputeTimes();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddPhase()
|
||
{
|
||
AddPhaseInternal(null);
|
||
RecomputeTimes();
|
||
}
|
||
|
||
private void AddPhaseInternal(LessonPhaseStep? source)
|
||
{
|
||
var item = new PhaseStepEditItem
|
||
{
|
||
Name = source?.Name ?? "",
|
||
DurationMinutes = source?.DurationMinutes ?? 5,
|
||
Activity = source?.Activity ?? "",
|
||
Material = source?.Material ?? "",
|
||
Shorthand = source?.Shorthand ?? "",
|
||
};
|
||
item.OnChanged = RecomputeTimes;
|
||
item.OnRemove = RemovePhase;
|
||
item.OnMoveUp = MovePhaseUp;
|
||
item.OnMoveDown = MovePhaseDown;
|
||
item.OnAssignAlternativePath = AssignAlternativePath;
|
||
if (source?.AlternativePathId is Guid pathId)
|
||
item.SetAlternativePath(_alternativePaths.GetById(pathId));
|
||
Phases.Add(item);
|
||
}
|
||
|
||
private async Task AssignAlternativePath(PhaseStepEditItem item)
|
||
{
|
||
if (OnPickAlternativePath is null) { item.SetAlternativePath(null); return; }
|
||
var picked = await OnPickAlternativePath(item.AlternativePathId);
|
||
item.SetAlternativePath(picked);
|
||
}
|
||
|
||
private void RemovePhase(PhaseStepEditItem item) { Phases.Remove(item); RecomputeTimes(); }
|
||
|
||
private void MovePhaseUp(PhaseStepEditItem item)
|
||
{
|
||
var idx = Phases.IndexOf(item);
|
||
if (idx > 0) Phases.Move(idx, idx - 1);
|
||
RecomputeTimes();
|
||
}
|
||
|
||
private void MovePhaseDown(PhaseStepEditItem item)
|
||
{
|
||
var idx = Phases.IndexOf(item);
|
||
if (idx >= 0 && idx < Phases.Count - 1) Phases.Move(idx, idx + 1);
|
||
RecomputeTimes();
|
||
}
|
||
|
||
partial void OnStartTimeTextChanged(string value) => RecomputeTimes();
|
||
/// Übernimmt beim Wählen der Stundennummer auch gleich den Beginn aus dem Stundenraster
|
||
/// (Einstellungen), sofern noch keiner eingetragen ist — beim Laden einer vorhandenen Stunde
|
||
/// wird das direkt danach vom tatsächlich gespeicherten <c>StartTime</c> überschrieben (auch
|
||
/// wenn das "kein Beginn hinterlegt" bedeutet), ein bereits eingetippter Beginn bleibt unangetastet.
|
||
partial void OnLessonNumberChanged(int? value)
|
||
{
|
||
if (value is int period && string.IsNullOrWhiteSpace(StartTimeText) &&
|
||
_periodSchedule.GetTimes(period) is { } times)
|
||
StartTimeText = times.Start.ToString("HH:mm");
|
||
RecomputeTimes();
|
||
}
|
||
partial void OnDateTextChanged(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);
|
||
}
|
||
|
||
RecomputeTimeBudget(total);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Vergleicht die geplante Gesamtdauer mit der laut Stundenraster (Einstellungen) tatsächlich
|
||
/// verfügbaren Zeit — Doppelstunden werden erkannt, indem ab der eingetragenen Stundennummer
|
||
/// so lange die jeweils nächste Periode addiert wird, wie der Stundenplan (4.3) für dieselbe
|
||
/// Gruppe/denselben Wochentag auch dort einen Slot hat (siehe <see cref="TimetableSlot"/>).
|
||
/// Ohne erkennbare Stunde/Datum oder ohne im Stundenraster hinterlegte Uhrzeiten bleibt die
|
||
/// Rückmeldung schlicht ausgeblendet, statt eine erfundene Dauer vorzutäuschen.
|
||
/// </summary>
|
||
private void RecomputeTimeBudget(int plannedMinutes)
|
||
{
|
||
if (LessonNumber is not int startPeriod ||
|
||
!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||
{ HasTimeBudgetInfo = false; return; }
|
||
|
||
var weekday = date.DayOfWeek;
|
||
var groupSlotsByPeriod = _timetableSlots.GetByGroup(_groupId)
|
||
.Where(s => s.Weekday == weekday)
|
||
.ToDictionary(s => s.PeriodNumber);
|
||
|
||
var available = _periodSchedule.GetDurationMinutes(startPeriod);
|
||
var period = startPeriod + 1;
|
||
while (groupSlotsByPeriod.ContainsKey(period))
|
||
{
|
||
available += _periodSchedule.GetDurationMinutes(period);
|
||
period++;
|
||
}
|
||
|
||
if (available <= 0) { HasTimeBudgetInfo = false; return; }
|
||
|
||
var utilizationPercent = (double)plannedMinutes / available * 100;
|
||
TimeBudgetColorHex = TimeBudgetColor(utilizationPercent);
|
||
TimeBudgetLabel = $"{plannedMinutes} von {available} Minuten geplant ({utilizationPercent:0}%)";
|
||
HasTimeBudgetInfo = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Farbskala für die Auslastung (geplante / verfügbare Minuten): 93–96 % gilt als guter
|
||
/// Zielbereich (grün) — ein kleiner Puffer, da 100 % erfahrungsgemäß schon knapp ist. Darüber
|
||
/// wird es zunehmend rötlich, deutlich über 100 % kräftig rot. Deutlich unter 93 % (zu viel
|
||
/// Luft) ist bewusst neutral/blau statt rot gehalten — kein Fehler, nur "hier geht noch was".
|
||
/// </summary>
|
||
private static string TimeBudgetColor(double utilizationPercent) => utilizationPercent switch
|
||
{
|
||
< 70 => "#90A4AE", // Blaugrau: deutlich zu wenig geplant
|
||
< 93 => "#FFC107", // Gelb: noch Luft nach oben
|
||
<= 96 => "#43A047", // Grün: guter Zielbereich
|
||
<= 100 => "#FB8C00", // Orange: knapp, kaum Puffer
|
||
<= 115 => "#E64A19", // Rotorange: leicht überplant
|
||
_ => "#B71C1C", // Dunkelrot: deutlich überplant
|
||
};
|
||
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
DateTextError = ""; TopicError = ""; StartTimeTextError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(Topic)) { TopicError = "Thema erforderlich."; valid = false; }
|
||
|
||
DateOnly date = default;
|
||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out date))
|
||
{ DateTextError = "Format TT.MM.JJJJ."; valid = false; }
|
||
|
||
TimeOnly? startTime = null;
|
||
if (!string.IsNullOrWhiteSpace(StartTimeText))
|
||
{
|
||
if (!TimeOnly.TryParseExact(StartTimeText, "HH:mm", null, DateTimeStyles.None, out var t))
|
||
{ StartTimeTextError = "Format HH:MM."; valid = false; }
|
||
else startTime = t;
|
||
}
|
||
|
||
if (!valid) return;
|
||
|
||
Result = _editingLesson ?? new Lesson { UnitId = _unitId, GroupId = _groupId };
|
||
Result.UnitId = _unitId;
|
||
Result.GroupId = _groupId;
|
||
Result.Date = date;
|
||
Result.LessonNumber = LessonNumber;
|
||
Result.Topic = Topic.Trim();
|
||
Result.StartTime = startTime;
|
||
Result.Phases = Phases.Select(p => p.ToModel()).ToList();
|
||
Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim();
|
||
Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim();
|
||
Result.Status = LessonStatusDisplay.FromName(StatusName);
|
||
_lessons.Save(Result);
|
||
}
|
||
}
|
||
|
||
// ── Zeile im Verlaufsplan-Editor (4.2.2) ──────────────────────────────────────
|
||
|
||
public partial class PhaseStepEditItem : ObservableObject
|
||
{
|
||
private static readonly string[] AlternativePathPalette =
|
||
["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27"];
|
||
|
||
[ObservableProperty] private string _name = "";
|
||
[ObservableProperty] private int _durationMinutes = 5;
|
||
[ObservableProperty] private string _activity = "";
|
||
[ObservableProperty] private string _material = "";
|
||
[ObservableProperty] private string _shorthand = "";
|
||
[ObservableProperty] private string _computedTimeDisplay = "";
|
||
|
||
/// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog
|
||
/// (<see cref="OnAssignAlternativePath"/>); checked→unchecked entfernt die Zuordnung.
|
||
/// Änderungen, die von <see cref="SetAlternativePath"/> selbst kommen, lösen das nicht erneut aus.
|
||
[ObservableProperty] private bool _hasAlternativePath;
|
||
[ObservableProperty] private string _alternativePathName = "";
|
||
[ObservableProperty] private string _alternativePathColorHex = "#9E9E9E";
|
||
|
||
private bool _suppressAlternativePathToggle;
|
||
|
||
public Guid? AlternativePathId { get; private set; }
|
||
|
||
public Action? OnChanged { get; set; }
|
||
public Action<PhaseStepEditItem>? OnRemove { get; set; }
|
||
public Action<PhaseStepEditItem>? OnMoveUp { get; set; }
|
||
public Action<PhaseStepEditItem>? OnMoveDown { get; set; }
|
||
public Func<PhaseStepEditItem, Task>? OnAssignAlternativePath { get; set; }
|
||
|
||
partial void OnDurationMinutesChanged(int value) => OnChanged?.Invoke();
|
||
|
||
partial void OnHasAlternativePathChanged(bool value)
|
||
{
|
||
if (_suppressAlternativePathToggle) return;
|
||
if (value) _ = OnAssignAlternativePath?.Invoke(this);
|
||
else SetAlternativePath(null);
|
||
}
|
||
|
||
/// Wird sowohl beim Laden einer bestehenden Zuordnung als auch nach dem Zuweisen-Dialog
|
||
/// aufgerufen (auch mit null bei Abbruch/Entfernen) — setzt Id/Anzeigename/Farbe konsistent
|
||
/// und unterdrückt dabei das erneute Öffnen des Dialogs über <see cref="OnHasAlternativePathChanged"/>.
|
||
public void SetAlternativePath(AlternativeLessonPath? path)
|
||
{
|
||
_suppressAlternativePathToggle = true;
|
||
AlternativePathId = path?.Id;
|
||
AlternativePathName = path?.Name ?? "";
|
||
AlternativePathColorHex = path is null ? "#9E9E9E" : ColorFor(path.Name);
|
||
HasAlternativePath = path is not null;
|
||
_suppressAlternativePathToggle = false;
|
||
OnChanged?.Invoke();
|
||
}
|
||
|
||
private static string ColorFor(string name)
|
||
{
|
||
var hash = 0;
|
||
foreach (var c in name) hash = hash * 31 + c;
|
||
return AlternativePathPalette[Math.Abs(hash) % AlternativePathPalette.Length];
|
||
}
|
||
|
||
[RelayCommand] private void Remove() => OnRemove?.Invoke(this);
|
||
[RelayCommand] private void MoveUp() => OnMoveUp?.Invoke(this);
|
||
[RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this);
|
||
|
||
public LessonPhaseStep ToModel() => new()
|
||
{
|
||
Name = Name.Trim(),
|
||
DurationMinutes = DurationMinutes,
|
||
Activity = Activity.Trim(),
|
||
Material = Material.Trim(),
|
||
Shorthand = Shorthand.Trim(),
|
||
AlternativePathId = AlternativePathId,
|
||
};
|
||
}
|
||
|
||
// ── Dialog: Alternativen Ablauf zuweisen/anlegen (4.2.2 Nachtrag) ────────────
|
||
|
||
public partial class AlternativePathDialogViewModel : ObservableObject
|
||
{
|
||
private readonly IAlternativeLessonPathRepository _repo;
|
||
|
||
[ObservableProperty] private AlternativeLessonPath? _selectedPath;
|
||
[ObservableProperty] private string _newName = "";
|
||
[ObservableProperty] private string _newDescription = "";
|
||
[ObservableProperty] private string _newNameError = "";
|
||
[ObservableProperty] private string _selectionError = "";
|
||
|
||
public ObservableCollection<AlternativeLessonPath> Available { get; } = [];
|
||
public AlternativeLessonPath? Result { get; private set; }
|
||
|
||
public AlternativePathDialogViewModel(IAlternativeLessonPathRepository repo, Guid? currentId)
|
||
{
|
||
_repo = repo;
|
||
foreach (var p in repo.GetAll()) Available.Add(p);
|
||
if (currentId is Guid id) SelectedPath = Available.FirstOrDefault(p => p.Id == id);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CreateNew()
|
||
{
|
||
NewNameError = "";
|
||
if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; }
|
||
|
||
var entry = new AlternativeLessonPath
|
||
{
|
||
Name = NewName.Trim(),
|
||
Description = string.IsNullOrWhiteSpace(NewDescription) ? null : NewDescription.Trim(),
|
||
};
|
||
try { _repo.Save(entry); }
|
||
catch (InvalidOperationException ex) { NewNameError = ex.Message; return; }
|
||
catch (ArgumentException ex) { NewNameError = ex.Message; return; }
|
||
|
||
Available.Add(entry);
|
||
SelectedPath = entry;
|
||
NewName = ""; NewDescription = "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Confirm()
|
||
{
|
||
SelectionError = "";
|
||
if (SelectedPath is null) { SelectionError = "Bitte einen Ablauf auswählen oder neu anlegen."; return; }
|
||
Result = SelectedPath;
|
||
}
|
||
}
|
||
|
||
// ── Dialog: Stunde verschieben (4.2.4) ────────────────────────────────────────
|
||
|
||
public partial class MoveLessonDialogViewModel : ObservableObject
|
||
{
|
||
[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: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
|
||
|
||
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
|
||
{
|
||
private readonly ITimetableSlotRepository _slots;
|
||
private readonly ILessonRepository _lessons;
|
||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||
private readonly PublicHolidayService _publicHolidays;
|
||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||
private readonly Guid _unitId;
|
||
private readonly Guid _groupId;
|
||
|
||
[ObservableProperty] private string _fromDateText;
|
||
[ObservableProperty] private string _toDateText;
|
||
[ObservableProperty] private string _dateError = "";
|
||
|
||
public LessonSeriesResult? Result { get; private set; }
|
||
|
||
public GenerateLessonSeriesDialogViewModel(ITimetableSlotRepository slots, ILessonRepository lessons,
|
||
ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays,
|
||
SchoolCalendarSettingsService calendarSettings, Guid unitId, Guid groupId,
|
||
DateOnly? defaultFrom, DateOnly? defaultTo)
|
||
{
|
||
_slots = slots; _lessons = lessons; _schoolHolidays = schoolHolidays;
|
||
_publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||
_unitId = unitId; _groupId = groupId;
|
||
_fromDateText = (defaultFrom ?? DateOnly.FromDateTime(DateTime.Today)).ToString("dd.MM.yyyy");
|
||
_toDateText = (defaultTo ?? DateOnly.FromDateTime(DateTime.Today).AddMonths(1)).ToString("dd.MM.yyyy");
|
||
}
|
||
|
||
/// Legt für jeden Wochentag/Stunde, den die Gruppe laut Stundenplan (4.3) hat, im gewählten
|
||
/// Zeitraum eine neue Lesson an. Schulferien/Feiertage werden übersprungen (dieselbe Prüfung
|
||
/// wie im Stundenplan-Wochenraster, siehe TimetableViewModel.IsFreeDay); für ein Datum, an dem
|
||
/// die Gruppe laut Stundenplan bereits eine Lesson hat (gleiches Datum + gleiche Stundennummer,
|
||
/// unabhängig von der Einheit — ein Lehrer kann an einem Termin nur eine tatsächliche Stunde
|
||
/// halten), wird nichts doppelt angelegt. Neue Stunden bekommen bewusst kein Thema — die
|
||
/// sonst übliche "Thema erforderlich"-Regel des manuellen "+Stunde"-Dialogs gilt hier nicht,
|
||
/// da diese Platzhalter zum späteren Ausfüllen gedacht sind.
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
DateError = "";
|
||
|
||
if (!DateOnly.TryParseExact(FromDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from) ||
|
||
!DateOnly.TryParseExact(ToDateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to))
|
||
{ DateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; return; }
|
||
if (to < from) { DateError = "Das Ende darf nicht vor dem Beginn liegen."; return; }
|
||
|
||
var slotsForGroup = _slots.GetByGroup(_groupId);
|
||
if (slotsForGroup.Count == 0)
|
||
{ DateError = "Für diese Gruppe ist noch keine Stunde im Stundenplan eingetragen."; return; }
|
||
|
||
var schoolHolidays = _schoolHolidays.GetAll();
|
||
var publicHolidayDates = new HashSet<DateOnly>();
|
||
for (var year = from.Year; year <= to.Year; year++)
|
||
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date);
|
||
|
||
var existing = _lessons.GetByGroupAndRange(_groupId, from, to)
|
||
.Select(l => (l.Date, l.LessonNumber)).ToHashSet();
|
||
|
||
int created = 0, skippedHoliday = 0, skippedExisting = 0;
|
||
for (var date = from; date <= to; date = date.AddDays(1))
|
||
{
|
||
var isFreeDay = publicHolidayDates.Contains(date) ||
|
||
schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
|
||
|
||
foreach (var slot in slotsForGroup.Where(s => s.Weekday == date.DayOfWeek))
|
||
{
|
||
if (isFreeDay) { skippedHoliday++; continue; }
|
||
if (existing.Contains((date, (int?)slot.PeriodNumber))) { skippedExisting++; continue; }
|
||
|
||
_lessons.Save(new Lesson
|
||
{
|
||
UnitId = _unitId,
|
||
GroupId = _groupId,
|
||
Date = date,
|
||
LessonNumber = slot.PeriodNumber,
|
||
Topic = "",
|
||
});
|
||
created++;
|
||
}
|
||
}
|
||
|
||
Result = new LessonSeriesResult(created, skippedHoliday, skippedExisting);
|
||
}
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
}
|
||
|
||
// ── Verlaufsplan-Ansicht (schreibgeschützt, für den Unterrichtseinsatz) ──────
|
||
|
||
/// Eine Phasen-Zeile in der schreibgeschützten Ansicht — reine Anzeige, keine Bearbeitung.
|
||
public record PhaseViewItem(string Name, int DurationMinutes, string TimeDisplay,
|
||
string Activity, string Material, string Shorthand);
|
||
|
||
/// Eine Gruppe von Phasen mit demselben <see cref="LessonPhaseStep.AlternativePathId"/>
|
||
/// ("Hauptweg" bei null). Jede Gruppe bekommt ihre eigene kumulierte Zeitberechnung ab
|
||
/// Lesson.StartTime — eine Alternative zeigt also "so würde die Uhr laufen, wenn man diesen Weg
|
||
/// von Stundenbeginn an nimmt", nicht ab einer gemeinsamen Verzweigungsstelle im Hauptweg.
|
||
public record PhaseGroupViewItem(string Label, bool IsMainPath, string? Description, List<PhaseViewItem> Phases);
|
||
|
||
public class LessonViewerViewModel
|
||
{
|
||
private const string MainPathLabel = "Hauptweg";
|
||
|
||
public string DateDisplay { get; }
|
||
public string Topic { get; }
|
||
public string StatusLabel { get; }
|
||
public string StartTimeDisplay { get; }
|
||
public string? Homework { get; }
|
||
public string? Reflection { get; }
|
||
public List<PhaseGroupViewItem> PhaseGroups { get; }
|
||
public bool HasAlternatives { get; }
|
||
|
||
public LessonViewerViewModel(Lesson lesson, IAlternativeLessonPathRepository alternativePaths)
|
||
{
|
||
DateDisplay = lesson.Date.ToString("dd.MM.yyyy");
|
||
Topic = lesson.Topic;
|
||
StatusLabel = LessonStatusDisplay.ToName(lesson.Status);
|
||
StartTimeDisplay = lesson.StartTime?.ToString("HH:mm") ?? "–";
|
||
Homework = lesson.Homework;
|
||
Reflection = lesson.Reflection;
|
||
|
||
var order = new List<string>();
|
||
var descriptions = new Dictionary<string, string?>();
|
||
var byLabel = new Dictionary<string, List<LessonPhaseStep>>();
|
||
foreach (var p in lesson.Phases)
|
||
{
|
||
string label; string? description = null;
|
||
if (p.AlternativePathId is Guid pathId)
|
||
{
|
||
var path = alternativePaths.GetById(pathId);
|
||
label = path?.Name ?? "Unbekannter Ablauf";
|
||
description = path?.Description;
|
||
}
|
||
else label = MainPathLabel;
|
||
|
||
if (!byLabel.TryGetValue(label, out var list))
|
||
{
|
||
list = [];
|
||
byLabel[label] = list;
|
||
descriptions[label] = description;
|
||
order.Add(label);
|
||
}
|
||
list.Add(p);
|
||
}
|
||
|
||
// Hauptweg immer zuerst, unabhängig davon, in welcher Reihenfolge Phasen angelegt wurden.
|
||
var orderedLabels = order.OrderBy(l => l == MainPathLabel ? 0 : 1).ToList();
|
||
PhaseGroups = orderedLabels
|
||
.Select(label => new PhaseGroupViewItem(
|
||
label, label == MainPathLabel, descriptions[label], BuildPhaseViewItems(byLabel[label], lesson.StartTime)))
|
||
.ToList();
|
||
HasAlternatives = PhaseGroups.Count > 1;
|
||
}
|
||
|
||
private static List<PhaseViewItem> BuildPhaseViewItems(List<LessonPhaseStep> steps, TimeOnly? startTime)
|
||
{
|
||
var cursor = startTime;
|
||
var items = new List<PhaseViewItem>();
|
||
foreach (var p in steps)
|
||
{
|
||
var timeDisplay = cursor is { } c ? $"ab {c:HH:mm}" : "";
|
||
items.Add(new PhaseViewItem(p.Name, p.DurationMinutes, timeDisplay, p.Activity, p.Material, p.Shorthand));
|
||
if (cursor is { } cc) cursor = cc.AddMinutes(p.DurationMinutes);
|
||
}
|
||
return items;
|
||
}
|
||
}
|