@@ -100,7 +100,10 @@ public class LessonPhaseStep
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum UnitStatus { Planned, Active, Completed }
|
public enum UnitStatus { Planned, Active, Completed }
|
||||||
public enum LessonStatus { Planned, Conducted }
|
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
||||||
|
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||||
|
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||||
|
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
|
||||||
|
public sealed class LessonSchedulingService(ILessonRepository lessons)
|
||||||
|
{
|
||||||
|
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
|
||||||
|
{
|
||||||
|
EnsureTargetIsFree(lesson, newDate, newPeriod);
|
||||||
|
var oldDate = lesson.Date;
|
||||||
|
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||||
|
|
||||||
|
if (shiftFollowing && delta != 0)
|
||||||
|
{
|
||||||
|
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||||
|
{
|
||||||
|
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
|
||||||
|
continue;
|
||||||
|
other.Date = other.Date.AddDays(delta);
|
||||||
|
lessons.Save(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lesson.Date = newDate;
|
||||||
|
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
|
||||||
|
lessons.Save(lesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
|
||||||
|
int newPeriod, TimeOnly? newStartTime)
|
||||||
|
{
|
||||||
|
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
|
||||||
|
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
|
||||||
|
EnsureTargetIsFree(source, newDate, newPeriod);
|
||||||
|
|
||||||
|
var first = new List<LessonPhaseStep>();
|
||||||
|
var second = new List<LessonPhaseStep>();
|
||||||
|
var elapsed = 0;
|
||||||
|
foreach (var phase in source.Phases)
|
||||||
|
{
|
||||||
|
var remainingInFirst = splitAfterMinutes - elapsed;
|
||||||
|
if (remainingInFirst <= 0)
|
||||||
|
second.Add(Clone(phase, phase.DurationMinutes));
|
||||||
|
else if (phase.DurationMinutes <= remainingInFirst)
|
||||||
|
first.Add(Clone(phase, phase.DurationMinutes));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
first.Add(Clone(phase, remainingInFirst));
|
||||||
|
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
|
||||||
|
}
|
||||||
|
elapsed += phase.DurationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
source.Phases = first;
|
||||||
|
var continuation = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = source.UnitId,
|
||||||
|
GroupId = source.GroupId,
|
||||||
|
Date = newDate,
|
||||||
|
LessonNumber = newPeriod,
|
||||||
|
StartTime = newStartTime,
|
||||||
|
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} – Fortsetzung",
|
||||||
|
Phases = second,
|
||||||
|
Homework = source.Homework,
|
||||||
|
HomeworkChecked = source.HomeworkChecked,
|
||||||
|
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||||
|
Reflection = source.Reflection,
|
||||||
|
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
|
||||||
|
};
|
||||||
|
source.Homework = null;
|
||||||
|
source.HomeworkChecked = false;
|
||||||
|
source.HomeworkCheckDismissed = false;
|
||||||
|
source.Reflection = null;
|
||||||
|
lessons.Save(source);
|
||||||
|
lessons.Save(continuation);
|
||||||
|
return continuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
|
||||||
|
{
|
||||||
|
if (period is null) return;
|
||||||
|
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
|
||||||
|
.Any(l => l.Id != source.Id && l.LessonNumber == period);
|
||||||
|
if (occupied)
|
||||||
|
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
|
||||||
|
{
|
||||||
|
Name = source.Name,
|
||||||
|
DurationMinutes = duration,
|
||||||
|
Activity = source.Activity,
|
||||||
|
Material = source.Material,
|
||||||
|
Shorthand = source.Shorthand,
|
||||||
|
AlternativePathId = source.AlternativePathId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LessonSchedulingServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned };
|
||||||
|
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active };
|
||||||
|
var repo = new FakeUnits();
|
||||||
|
repo.Add(planned); repo.Add(active);
|
||||||
|
var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3);
|
||||||
|
|
||||||
|
Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id);
|
||||||
|
vm.NewUnitTitle = "Neue Reihe";
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.ResultIsNew);
|
||||||
|
Assert.Equal("Neue Reihe", vm.Result?.Title);
|
||||||
|
Assert.Null(repo.GetById(vm.Result!.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 };
|
||||||
|
var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft };
|
||||||
|
var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted);
|
||||||
|
|
||||||
|
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 8), moved.Date);
|
||||||
|
Assert.Equal(5, moved.LessonNumber);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||||
|
{
|
||||||
|
var source = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||||
|
LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2",
|
||||||
|
Phases =
|
||||||
|
[
|
||||||
|
new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 },
|
||||||
|
new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 },
|
||||||
|
new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source);
|
||||||
|
|
||||||
|
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45,
|
||||||
|
new(2026, 9, 3), 6, new TimeOnly(12, 15));
|
||||||
|
|
||||||
|
Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes));
|
||||||
|
Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes));
|
||||||
|
Assert.Equal("Fotosynthese – Fortsetzung", continuation.Topic);
|
||||||
|
Assert.Null(source.Homework);
|
||||||
|
Assert.Equal("Aufgabe 2", continuation.Homework);
|
||||||
|
Assert.Equal(6, continuation.LessonNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_LehntBelegtenZielterminAb()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 };
|
||||||
|
var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source); repo.Add(occupied);
|
||||||
|
|
||||||
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false));
|
||||||
|
|
||||||
|
Assert.Contains("bereits eine Planung", error.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,7 +277,23 @@ public sealed class TimetableViewModelTests
|
|||||||
|
|
||||||
Assert.Equal(group.Id, navigatedTo);
|
Assert.Equal(group.Id, navigatedTo);
|
||||||
Assert.False(viewerOpened);
|
Assert.False(viewerOpened);
|
||||||
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel);
|
Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||||
|
TimetableLessonRequest? requested = null;
|
||||||
|
vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||||
|
|
||||||
|
Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadNextLesson(DateOnly today)
|
private void LoadNextLesson(DateOnly today)
|
||||||
{
|
{
|
||||||
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
||||||
.Where(l => l.Status == LessonStatus.Planned)
|
.Where(l => l.Status != LessonStatus.Conducted)
|
||||||
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
|||||||
|
|
||||||
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
|
// ── Ergebnisse der Verschieben-/Kopieren-/Serienerzeugungs-Dialoge (4.2.4 / 4.1.4 / 4.2.5) ───
|
||||||
|
|
||||||
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing);
|
public record MoveLessonTarget(DateOnly NewDate, bool ShiftFollowing, int? NewPeriod = null,
|
||||||
|
bool SplitDoubleLesson = false);
|
||||||
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
public record CopyUnitTarget(Guid TargetGroupId, DateOnly AnchorDate);
|
||||||
|
|
||||||
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
|
public record LessonSeriesResult(int Created, int SkippedHoliday, int SkippedExisting)
|
||||||
@@ -78,6 +79,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||||
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||||
public Action<string>? OnNotify { get; set; }
|
public Action<string>? OnNotify { get; set; }
|
||||||
|
public Action<string>? OnError { get; set; }
|
||||||
|
|
||||||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||||||
IGroupRepository groups, ISubjectRepository subjects,
|
IGroupRepository groups, ISubjectRepository subjects,
|
||||||
@@ -333,7 +335,8 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
var lesson = SelectedLesson.Model;
|
var lesson = SelectedLesson.Model;
|
||||||
var target = await OnPickMoveTarget(lesson);
|
var target = await OnPickMoveTarget(lesson);
|
||||||
if (target is null) return;
|
if (target is null) return;
|
||||||
MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing);
|
try { MoveLessonInternal(lesson, target.NewDate, target.ShiftFollowing, target.NewPeriod); }
|
||||||
|
catch (InvalidOperationException ex) { OnError?.Invoke(ex.Message); return; }
|
||||||
LoadUnits();
|
LoadUnits();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,33 +344,19 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
/// sich alle anderen noch geplanten Stunden derselben Einheit, die ursprünglich NACH der
|
/// 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
|
/// verschobenen Stunde lagen, um denselben Tages-Delta. Bereits durchgeführte Stunden werden
|
||||||
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
|
/// nie angefasst — nur Date ändert sich, UnitId/GroupId bleiben unverändert.
|
||||||
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing)
|
private void MoveLessonInternal(Lesson moved, DateOnly newDate, bool shiftFollowing, int? newPeriod = null)
|
||||||
{
|
{
|
||||||
var oldDate = moved.Date;
|
new LessonSchedulingService(_lessons).Move(moved, newDate, newPeriod, shiftFollowing);
|
||||||
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))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private void AdvanceLessonStatus()
|
private void AdvanceLessonStatus()
|
||||||
{
|
{
|
||||||
if (SelectedLesson is null || SelectedLesson.Model.Status != LessonStatus.Planned) return;
|
if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return;
|
||||||
var lesson = SelectedLesson.Model;
|
var lesson = SelectedLesson.Model;
|
||||||
lesson.Status = LessonStatus.Conducted;
|
lesson.Status = lesson.Status == LessonStatus.Ready
|
||||||
|
? LessonStatus.Conducted
|
||||||
|
: LessonStatus.Ready;
|
||||||
_lessons.Save(lesson);
|
_lessons.Save(lesson);
|
||||||
LoadUnits();
|
LoadUnits();
|
||||||
}
|
}
|
||||||
@@ -479,8 +468,14 @@ public class LessonSummary
|
|||||||
Topic = l.Topic;
|
Topic = l.Topic;
|
||||||
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–";
|
StartTimeDisplay = l.StartTime?.ToString("HH:mm") ?? "–";
|
||||||
Status = l.Status;
|
Status = l.Status;
|
||||||
StatusLabel = l.Status == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
StatusLabel = LessonStatusDisplay.ToName(l.Status);
|
||||||
StatusColorHex = l.Status == LessonStatus.Conducted ? "#43A047" : "#9E9E9E";
|
StatusColorHex = l.Status switch
|
||||||
|
{
|
||||||
|
LessonStatus.Draft => "#78909C",
|
||||||
|
LessonStatus.Ready => "#1976D2",
|
||||||
|
LessonStatus.Conducted => "#43A047",
|
||||||
|
_ => "#9E9E9E",
|
||||||
|
};
|
||||||
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
||||||
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
|
var totalMinutes = l.Phases.Sum(p => p.DurationMinutes);
|
||||||
TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min.";
|
TotalDurationLabel = totalMinutes == 0 ? "–" : $"{totalMinutes} Min.";
|
||||||
@@ -515,12 +510,23 @@ public static class UnitStatusDisplay
|
|||||||
|
|
||||||
public static class LessonStatusDisplay
|
public static class LessonStatusDisplay
|
||||||
{
|
{
|
||||||
public static string[] Options { get; } = ["Geplant", "Durchgeführt"];
|
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"];
|
||||||
|
|
||||||
public static string ToName(LessonStatus s) => s == LessonStatus.Conducted ? "Durchgeführt" : "Geplant";
|
public static string ToName(LessonStatus s) => s switch
|
||||||
|
{
|
||||||
|
LessonStatus.Draft => "Entwurf",
|
||||||
|
LessonStatus.Ready => "Bereit",
|
||||||
|
LessonStatus.Conducted => "Durchgeführt",
|
||||||
|
_ => "Geplant",
|
||||||
|
};
|
||||||
|
|
||||||
public static LessonStatus FromName(string? name) =>
|
public static LessonStatus FromName(string? name) => name switch
|
||||||
name == "Durchgeführt" ? LessonStatus.Conducted : LessonStatus.Planned;
|
{
|
||||||
|
"Entwurf" => LessonStatus.Draft,
|
||||||
|
"Bereit" => LessonStatus.Ready,
|
||||||
|
"Durchgeführt" => LessonStatus.Conducted,
|
||||||
|
_ => LessonStatus.Planned,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
|
// ── Dialog: Einheit anlegen / bearbeiten (4.1.2 / 4.1.3) ─────────────────────
|
||||||
@@ -722,7 +728,8 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots,
|
||||||
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
|
PeriodScheduleService periodSchedule, IAttachmentStorage attachmentStorage,
|
||||||
Guid unitId, Guid groupId, string groupName, string subjectName,
|
Guid unitId, Guid groupId, string groupName, string subjectName,
|
||||||
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson)
|
List<string> materialSuggestions, List<string> shorthandHistorySuggestions, Lesson? editingLesson,
|
||||||
|
DateOnly? suggestedDate = null, int? suggestedPeriod = null)
|
||||||
{
|
{
|
||||||
_lessons = lessons; _alternativePaths = alternativePaths;
|
_lessons = lessons; _alternativePaths = alternativePaths;
|
||||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||||
@@ -760,9 +767,12 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var (date, lessonNumber) = SuggestNextLesson();
|
var (date, lessonNumber) = suggestedDate.HasValue
|
||||||
|
? (suggestedDate.Value, suggestedPeriod)
|
||||||
|
: SuggestNextLesson();
|
||||||
DateText = date.ToString("dd.MM.yyyy");
|
DateText = date.ToString("dd.MM.yyyy");
|
||||||
LessonNumber = lessonNumber;
|
LessonNumber = lessonNumber;
|
||||||
|
StatusName = LessonStatusDisplay.ToName(LessonStatus.Draft);
|
||||||
}
|
}
|
||||||
RecomputeTimes();
|
RecomputeTimes();
|
||||||
}
|
}
|
||||||
@@ -1145,28 +1155,45 @@ public partial class AlternativePathDialogViewModel : ObservableObject
|
|||||||
public partial class MoveLessonDialogViewModel : ObservableObject
|
public partial class MoveLessonDialogViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
[ObservableProperty] private string _newDateText;
|
[ObservableProperty] private string _newDateText;
|
||||||
|
[ObservableProperty] private int? _newPeriod;
|
||||||
[ObservableProperty] private bool _shiftFollowingPlanned = true;
|
[ObservableProperty] private bool _shiftFollowingPlanned = true;
|
||||||
|
[ObservableProperty] private bool _splitDoubleLesson;
|
||||||
[ObservableProperty] private string _newDateTextError = "";
|
[ObservableProperty] private string _newDateTextError = "";
|
||||||
|
[ObservableProperty] private string _newPeriodError = "";
|
||||||
|
|
||||||
public string CurrentDateDisplay { get; }
|
public string CurrentDateDisplay { get; }
|
||||||
|
public string CurrentPeriodDisplay { get; }
|
||||||
|
public bool CanSplitDoubleLesson { get; }
|
||||||
public MoveLessonTarget? Result { get; private set; }
|
public MoveLessonTarget? Result { get; private set; }
|
||||||
|
|
||||||
public MoveLessonDialogViewModel(DateOnly currentDate)
|
public MoveLessonDialogViewModel(DateOnly currentDate, int? currentPeriod = null,
|
||||||
|
bool canSplitDoubleLesson = false, bool splitByDefault = false)
|
||||||
{
|
{
|
||||||
CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
|
CurrentDateDisplay = currentDate.ToString("dd.MM.yyyy");
|
||||||
|
CurrentPeriodDisplay = currentPeriod is null ? "nicht festgelegt" : $"{currentPeriod}. Stunde";
|
||||||
_newDateText = currentDate.ToString("dd.MM.yyyy");
|
_newDateText = currentDate.ToString("dd.MM.yyyy");
|
||||||
|
_newPeriod = currentPeriod;
|
||||||
|
CanSplitDoubleLesson = canSplitDoubleLesson;
|
||||||
|
_splitDoubleLesson = canSplitDoubleLesson && splitByDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
NewDateTextError = "";
|
NewDateTextError = "";
|
||||||
|
NewPeriodError = "";
|
||||||
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
if (!DateOnly.TryParseExact(NewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||||
{
|
{
|
||||||
NewDateTextError = "Format TT.MM.JJJJ.";
|
NewDateTextError = "Format TT.MM.JJJJ.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Result = new MoveLessonTarget(date, ShiftFollowingPlanned);
|
if (NewPeriod is < 1 or > 20)
|
||||||
|
{
|
||||||
|
NewPeriodError = "Bitte eine Stundennummer zwischen 1 und 20 wählen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Result = new MoveLessonTarget(date, ShiftFollowingPlanned, NewPeriod,
|
||||||
|
CanSplitDoubleLesson && SplitDoubleLesson);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
|
||||||
|
public sealed class TimetableUnitOption(Unit unit)
|
||||||
|
{
|
||||||
|
public Unit Model { get; } = unit;
|
||||||
|
public string Label { get; } = unit.Title;
|
||||||
|
public string Detail { get; } = unit.Status switch
|
||||||
|
{
|
||||||
|
UnitStatus.Active => "Laufende Einheit",
|
||||||
|
UnitStatus.Completed => "Abgeschlossene Einheit",
|
||||||
|
_ => "Geplante Einheit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ordnet eine direkt aus dem Stundenplan angelegte Stunde einer Einheit zu.</summary>
|
||||||
|
public partial class TimetableUnitPickerViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IUnitRepository _units;
|
||||||
|
private readonly Guid _groupId;
|
||||||
|
private readonly DateOnly _date;
|
||||||
|
|
||||||
|
public string ContextLabel { get; }
|
||||||
|
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
|
||||||
|
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
|
||||||
|
[ObservableProperty] private string _newUnitTitle = "";
|
||||||
|
[ObservableProperty] private string _error = "";
|
||||||
|
public Unit? Result { get; private set; }
|
||||||
|
public bool ResultIsNew { get; private set; }
|
||||||
|
public bool HasUnits => Units.Count > 0;
|
||||||
|
|
||||||
|
public TimetableUnitPickerViewModel(IUnitRepository units, Guid groupId, string groupName,
|
||||||
|
DateOnly date, int period)
|
||||||
|
{
|
||||||
|
_units = units;
|
||||||
|
_groupId = groupId;
|
||||||
|
_date = date;
|
||||||
|
ContextLabel = $"{groupName} · {date:dd.MM.yyyy} · {period}. Stunde";
|
||||||
|
|
||||||
|
foreach (var unit in units.GetByGroup(groupId)
|
||||||
|
.OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2)
|
||||||
|
.ThenByDescending(u => u.StartDate)
|
||||||
|
.ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase))
|
||||||
|
Units.Add(new TimetableUnitOption(unit));
|
||||||
|
SelectedUnit = Units.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
Error = "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(NewUnitTitle))
|
||||||
|
{
|
||||||
|
Result = new Unit
|
||||||
|
{
|
||||||
|
GroupId = _groupId,
|
||||||
|
Title = NewUnitTitle.Trim(),
|
||||||
|
StartDate = _date,
|
||||||
|
Status = UnitStatus.Active,
|
||||||
|
};
|
||||||
|
// Erst speichern, wenn auch der anschließende Stunden-Dialog bestätigt wurde. So
|
||||||
|
// hinterlässt ein Abbruch keine leere Einheit.
|
||||||
|
ResultIsNew = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SelectedUnit is null)
|
||||||
|
{
|
||||||
|
Error = "Bitte eine Einheit auswählen oder eine neue benennen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Result = SelectedUnit.Model;
|
||||||
|
ResultIsNew = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TimetableLessonRequest(Guid GroupId, DateOnly Date, int PeriodNumber);
|
||||||
|
public sealed record TimetableLessonMoveRequest(Lesson Lesson, int SelectedPeriod);
|
||||||
@@ -5,6 +5,7 @@ using LehrerApp.Core.Models;
|
|||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||||||
@@ -108,6 +109,8 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
|
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
|
||||||
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
|
||||||
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
|
||||||
|
public Func<TimetableLessonRequest, Task>? OnCreateLesson { get; set; }
|
||||||
|
public Func<TimetableLessonMoveRequest, Task>? OnMoveLesson { get; set; }
|
||||||
|
|
||||||
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
|
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
|
||||||
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
|
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
|
||||||
@@ -301,9 +304,9 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
|
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
|
||||||
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
|
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
|
||||||
|
|
||||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
|
var lesson = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber);
|
||||||
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
|
||||||
items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
|
items.Add(new TodayLessonItem(slot.GroupId, today, slot.PeriodNumber, group.Name,
|
||||||
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
|
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
|
||||||
HasUnhandledHomework(slot.GroupId, today), lesson));
|
HasUnhandledHomework(slot.GroupId, today), lesson));
|
||||||
}
|
}
|
||||||
@@ -353,15 +356,15 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
|
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
|
||||||
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson (Slot laut Stundenplan belegt,
|
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage;
|
||||||
/// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation
|
/// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit.
|
||||||
/// zum Planung-Tab der Gruppe: eine neue Lesson direkt von hier aus anzulegen bräuchte eine
|
|
||||||
/// Antwort auf "welcher Unit wird sie zugeordnet", die bewusst noch offen ist (siehe TODO 4.5.2).
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenTodayLesson(TodayLessonItem? item)
|
private async Task OpenTodayLesson(TodayLessonItem? item)
|
||||||
{
|
{
|
||||||
if (item is null || item.GroupId == Guid.Empty) return;
|
if (item is null || item.GroupId == Guid.Empty) return;
|
||||||
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||||
|
else if (OnCreateLesson is not null)
|
||||||
|
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, item.Date, item.PeriodNumber));
|
||||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +383,7 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
|
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
|
||||||
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
|
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
|
||||||
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
|
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
|
||||||
/// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik.
|
/// daneben die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an.
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenWeekCell(WeekCellItem? item)
|
private async Task OpenWeekCell(WeekCellItem? item)
|
||||||
{
|
{
|
||||||
@@ -391,6 +394,8 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
await OnOpenTeachingMode(lesson);
|
await OnOpenTeachingMode(lesson);
|
||||||
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
|
||||||
}
|
}
|
||||||
|
else if (item.Date is { } date && OnCreateLesson is not null)
|
||||||
|
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, date, item.PeriodNumber));
|
||||||
else OnNavigateToGroup?.Invoke(item.GroupId);
|
else OnNavigateToGroup?.Invoke(item.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,16 +404,20 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
|
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
|
||||||
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
|
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
|
||||||
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
|
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
|
||||||
/// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort
|
/// Nimmt das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die
|
||||||
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot).
|
/// Direktanlage einer noch nicht existierenden Stunde.
|
||||||
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
|
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
|
||||||
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
|
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
|
||||||
{
|
{
|
||||||
var nowSnapshot = Clock();
|
var nowSnapshot = Clock();
|
||||||
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
|
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
|
||||||
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
|
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
|
||||||
var now = TimeOnly.FromDateTime(nowSnapshot);
|
// DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen
|
||||||
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
|
// Tagesrand und macht aus z.B. 00:05 ± 10 Minuten ein umgekehrtes Vergleichsfenster.
|
||||||
|
var start = lessonDate.ToDateTime(times.Start).Subtract(TeachingTimeTolerance);
|
||||||
|
var end = lessonDate.ToDateTime(times.End).Add(TeachingTimeTolerance);
|
||||||
|
if (end < start) end = end.AddDays(1); // nur für ein ggf. über Mitternacht laufendes Raster
|
||||||
|
return nowSnapshot >= start && nowSnapshot <= end;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -487,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
|
var lesson = FindLessonForSlot(slot.GroupId, date, period);
|
||||||
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
|
||||||
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
|
||||||
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
|
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
|
||||||
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
|
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
|
||||||
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
|
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
|
||||||
|
|
||||||
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today,
|
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, date,
|
||||||
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
|
||||||
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
|
||||||
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
colorHex, holidayBadge, hasExam, isLastBeforeExam,
|
||||||
@@ -560,6 +569,31 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
|
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
||||||
|
|
||||||
|
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
|
||||||
|
/// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar
|
||||||
|
/// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an
|
||||||
|
/// der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||||
|
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
|
||||||
|
{
|
||||||
|
var lessons = _lessons.GetByGroupAndDate(groupId, date);
|
||||||
|
var exact = lessons.FirstOrDefault(l => l.LessonNumber == period);
|
||||||
|
if (exact is not null) return exact;
|
||||||
|
// Historische/manuell angelegte Einträge hatten häufig keine Stundennummer. Solange es
|
||||||
|
// davon nur einen an diesem Tag gibt, bleibt das frühere Verhalten erhalten und er wird
|
||||||
|
// dem vorhandenen Gruppen-Slot zugeordnet.
|
||||||
|
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
|
||||||
|
if (withoutPeriod.Count == 1) return withoutPeriod[0];
|
||||||
|
|
||||||
|
var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1)
|
||||||
|
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||||
|
if (previous?.LessonNumber is not int anchor) return null;
|
||||||
|
var isConsecutiveSlot = _slots.GetByGroup(groupId)
|
||||||
|
.Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period);
|
||||||
|
var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor);
|
||||||
|
return isConsecutiveSlot && firstPeriodMinutes > 0 &&
|
||||||
|
previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
||||||
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
|
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
|
||||||
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
|
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
|
||||||
@@ -824,7 +858,9 @@ public partial class WeekCellItem : ObservableObject
|
|||||||
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
|
||||||
public Lesson? Lesson { get; private init; }
|
public Lesson? Lesson { get; private init; }
|
||||||
public bool HasLesson => Lesson is not null;
|
public bool HasLesson => Lesson is not null;
|
||||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
public bool HasNoLesson => Lesson is null;
|
||||||
|
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||||
|
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||||
[ObservableProperty] private string _weatherSymbol = "";
|
[ObservableProperty] private string _weatherSymbol = "";
|
||||||
[ObservableProperty] private string _weatherTooltip = "";
|
[ObservableProperty] private string _weatherTooltip = "";
|
||||||
[ObservableProperty] private bool _hasWeatherWarning;
|
[ObservableProperty] private bool _hasWeatherWarning;
|
||||||
@@ -879,18 +915,28 @@ public partial class WeekCellItem : ObservableObject
|
|||||||
IsSubstitutionSupervision = isSubstitution,
|
IsSubstitutionSupervision = isSubstitution,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, DateOnly date, string subjectLabel,
|
||||||
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||||
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||||
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
|
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
|
||||||
{
|
{
|
||||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
|
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, Date = date,
|
||||||
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
|
||||||
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
|
||||||
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
|
||||||
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
|
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kompatible Überladung für isolierte ViewModel-Tests und ältere Aufrufer ohne konkreten
|
||||||
|
// Wochenbezug. Produktiv wird die datierte Variante verwendet.
|
||||||
|
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
|
||||||
|
string groupName, string room, string topic, string colorHex, string holidayBadge,
|
||||||
|
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
|
||||||
|
bool hasUnhandledHomework = false, Lesson? lesson = null) => ForSlot(day, period, isToday,
|
||||||
|
DateOnly.FromDateTime(DateTime.Today), subjectLabel, groupName, room, topic, colorHex,
|
||||||
|
holidayBadge, hasExam, isLastBeforeExam, hasExperiment, groupId, isHoliday,
|
||||||
|
hasUnhandledHomework, lesson);
|
||||||
|
|
||||||
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
|
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
|
||||||
{
|
{
|
||||||
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
|
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
|
||||||
@@ -937,12 +983,13 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
|
|||||||
public string Title { get; } = title;
|
public string Title { get; } = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
|
public enum TimetableLessonDestination { TeachingMode, Viewer, Create, Move, SeatingPlan, Planning }
|
||||||
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
|
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
|
||||||
|
|
||||||
public class TodayLessonItem
|
public class TodayLessonItem
|
||||||
{
|
{
|
||||||
public Guid GroupId { get; private init; }
|
public Guid GroupId { get; private init; }
|
||||||
|
public DateOnly Date { get; private init; }
|
||||||
public int PeriodNumber { get; private init; }
|
public int PeriodNumber { get; private init; }
|
||||||
public string GroupName { get; private init; } = "";
|
public string GroupName { get; private init; } = "";
|
||||||
public string Room { get; private init; } = "";
|
public string Room { get; private init; } = "";
|
||||||
@@ -960,23 +1007,24 @@ public class TodayLessonItem
|
|||||||
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
|
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
|
||||||
public Lesson? Lesson { get; private init; }
|
public Lesson? Lesson { get; private init; }
|
||||||
public bool HasLesson => Lesson is not null;
|
public bool HasLesson => Lesson is not null;
|
||||||
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
|
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
|
||||||
|
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
|
||||||
|
|
||||||
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
|
public TodayLessonItem(Guid groupId, DateOnly date, int periodNumber, string groupName, string room,
|
||||||
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
|
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
|
||||||
Lesson? lesson = null)
|
Lesson? lesson = null)
|
||||||
{
|
{
|
||||||
GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
GroupId = groupId; Date = date; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
|
||||||
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
|
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
|
||||||
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
|
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
|
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
|
||||||
entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
entry.GroupId ?? Guid.Empty, entry.Date, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
|
||||||
{ IsSubstitution = true };
|
{ IsSubstitution = true };
|
||||||
|
|
||||||
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
|
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
|
||||||
groupId, periodNumber, groupName, "", "#757575",
|
groupId, entry.Date, periodNumber, groupName, "", "#757575",
|
||||||
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
|
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
|
||||||
{ IsCancelled = true };
|
{ IsCancelled = true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
x:Class="LehrerApp.Desktop.Views.Groups.MoveLessonDialog"
|
x:Class="LehrerApp.Desktop.Views.Groups.MoveLessonDialog"
|
||||||
x:DataType="vm:MoveLessonDialogViewModel"
|
x:DataType="vm:MoveLessonDialogViewModel"
|
||||||
Title="Stunde verschieben"
|
Title="Stunde verschieben"
|
||||||
Width="400" Height="260" MinWidth="360" MinHeight="240"
|
Width="440" Height="430" MinWidth="400" MinHeight="400"
|
||||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
<TextBlock FontSize="12" Opacity="0.6">
|
<TextBlock FontSize="12" Opacity="0.6">
|
||||||
<Run Text="Bisheriges Datum: "/>
|
<Run Text="Bisheriges Datum: "/>
|
||||||
<Run Text="{Binding CurrentDateDisplay}"/>
|
<Run Text="{Binding CurrentDateDisplay}"/>
|
||||||
|
<Run Text=" · "/>
|
||||||
|
<Run Text="{Binding CurrentPeriodDisplay}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
@@ -22,6 +24,24 @@
|
|||||||
IsVisible="{Binding NewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding NewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Neue Stunde" FontSize="12" Opacity="0.7"/>
|
||||||
|
<NumericUpDown Value="{Binding NewPeriod}" Minimum="1" Maximum="20"
|
||||||
|
FormatString="0" PlaceholderText="Stundennummer"/>
|
||||||
|
<TextBlock Text="{Binding NewPeriodError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding NewPeriodError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="6"
|
||||||
|
Padding="10" IsVisible="{Binding CanSplitDoubleLesson}">
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<CheckBox Content="Nur den zweiten Teil der Doppelstunde verschieben"
|
||||||
|
IsChecked="{Binding SplitDoubleLesson}"/>
|
||||||
|
<TextBlock Text="Der Verlauf wird an der Dauer der ersten Stunde geteilt. Der zweite Teil wird als eigene Fortsetzungsstunde angelegt."
|
||||||
|
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<CheckBox Content="Folgestunden automatisch verschieben"
|
<CheckBox Content="Folgestunden automatisch verschieben"
|
||||||
IsChecked="{Binding ShiftFollowingPlanned}"
|
IsChecked="{Binding ShiftFollowingPlanned}"
|
||||||
ToolTip.Tip="Verschiebt alle noch geplanten Stunden derselben Einheit, die nach dieser Stunde liegen, um denselben Zeitraum. Bereits durchgeführte Stunden bleiben unverändert."/>
|
ToolTip.Tip="Verschiebt alle noch geplanten Stunden derselben Einheit, die nach dieser Stunde liegen, um denselben Zeitraum. Bereits durchgeführte Stunden bleiben unverändert."/>
|
||||||
|
|||||||
@@ -91,7 +91,8 @@
|
|||||||
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
||||||
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
<Button Content="Status weiter" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"
|
||||||
|
ToolTip.Tip="Entwurf/Geplant → Bereit → Durchgeführt"/>
|
||||||
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}"
|
<Button Content="Sitzung erzeugen" Command="{Binding CreateParticipationSessionCommand}"
|
||||||
IsEnabled="{Binding !IsReadOnly}"
|
IsEnabled="{Binding !IsReadOnly}"
|
||||||
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
|
ToolTip.Tip="Legt eine Mitarbeitssitzung mit Datum und Thema dieser Stunde an."/>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public partial class PlanningTabView : UserControl
|
|||||||
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
||||||
vm.OnAiAssist = ShowAiAssistDialog;
|
vm.OnAiAssist = ShowAiAssistDialog;
|
||||||
vm.OnNotify = Notifications.ShowSuccess;
|
vm.OnNotify = Notifications.ShowSuccess;
|
||||||
|
vm.OnError = Notifications.ShowError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ public partial class PlanningTabView : UserControl
|
|||||||
|
|
||||||
private async Task<MoveLessonTarget?> ShowMoveLessonDialog(Lesson lesson)
|
private async Task<MoveLessonTarget?> ShowMoveLessonDialog(Lesson lesson)
|
||||||
{
|
{
|
||||||
var dialogVm = new MoveLessonDialogViewModel(lesson.Date);
|
var dialogVm = new MoveLessonDialogViewModel(lesson.Date, lesson.LessonNumber);
|
||||||
var dialog = new MoveLessonDialog { DataContext = dialogVm };
|
var dialog = new MoveLessonDialog { DataContext = dialogVm };
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner is null) return null;
|
if (owner is null) return null;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Planning.TimetableUnitPickerDialog"
|
||||||
|
x:DataType="vm:TimetableUnitPickerViewModel"
|
||||||
|
Title="Einheit für die Stunde wählen"
|
||||||
|
Width="470" Height="390" MinWidth="420" MinHeight="360"
|
||||||
|
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="16">
|
||||||
|
<TextBlock Text="Stunde anlegen" Classes="dialogtitle"/>
|
||||||
|
<TextBlock Text="{Binding ContextLabel}" FontSize="12" Opacity="0.65"/>
|
||||||
|
<TextBlock Text="Die Stunde braucht eine Unterrichtseinheit. Laufende Einheiten werden zuerst vorgeschlagen."
|
||||||
|
FontSize="12" TextWrapping="Wrap" Opacity="0.75"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="5" IsVisible="{Binding HasUnits}">
|
||||||
|
<TextBlock Text="Vorhandene Einheit" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Units}" SelectedItem="{Binding SelectedUnit}"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TimetableUnitOption">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<TextBlock Text="Oder neue Einheit anlegen" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding NewUnitTitle}" PlaceholderText="Titel der neuen Einheit"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Weiter zur Planung" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Planning;
|
||||||
|
|
||||||
|
public partial class TimetableUnitPickerDialog : Window
|
||||||
|
{
|
||||||
|
public TimetableUnitPickerDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnSave(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TimetableUnitPickerViewModel vm) return;
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
if (vm.Result is not null) Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Text="{Binding LessonTopic}" FontSize="11" Opacity="0.75"
|
<TextBlock Text="{Binding LessonTopic}" FontSize="11" Opacity="0.75"
|
||||||
TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/>
|
TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/>
|
||||||
|
<TextBlock Text="{Binding PlanningStatusLabel}" FontSize="10" Opacity="0.62"/>
|
||||||
<TextBlock Text="{Binding ExamTitle}" FontSize="11" Foreground="#D85A30" FontWeight="SemiBold"
|
<TextBlock Text="{Binding ExamTitle}" FontSize="11" Foreground="#D85A30" FontWeight="SemiBold"
|
||||||
IsVisible="{Binding HasExam}"/>
|
IsVisible="{Binding HasExam}"/>
|
||||||
<TextBlock Text="📓 Hausaufgabe aus letzter Stunde noch nicht kontrolliert" FontSize="11"
|
<TextBlock Text="📓 Hausaufgabe aus letzter Stunde noch nicht kontrolliert" FontSize="11"
|
||||||
@@ -258,6 +259,8 @@
|
|||||||
IsVisible="{Binding HasRoom}"/>
|
IsVisible="{Binding HasRoom}"/>
|
||||||
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
|
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
|
||||||
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
|
||||||
|
<TextBlock Text="{Binding PlanningStatusLabel}" FontSize="9" Foreground="White"
|
||||||
|
Opacity="0.78" Margin="0,1,0,0"/>
|
||||||
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||||
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
|
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
|
||||||
<TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
<TextBlock Text="Ausfall" FontSize="10" Foreground="White" FontWeight="SemiBold"
|
||||||
@@ -293,6 +296,12 @@
|
|||||||
<MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}"
|
<MenuItem Header="📋 Planungsviewer" IsVisible="{Binding HasLesson}"
|
||||||
Tag="{x:Static vm:TimetableLessonDestination.Viewer}"
|
Tag="{x:Static vm:TimetableLessonDestination.Viewer}"
|
||||||
Click="OnWeekCellMenuItemClick"/>
|
Click="OnWeekCellMenuItemClick"/>
|
||||||
|
<MenuItem Header="+ Stunde anlegen" IsVisible="{Binding HasNoLesson}"
|
||||||
|
Tag="{x:Static vm:TimetableLessonDestination.Create}"
|
||||||
|
Click="OnWeekCellMenuItemClick"/>
|
||||||
|
<MenuItem Header="↪ Stunde verschieben" IsVisible="{Binding HasLesson}"
|
||||||
|
Tag="{x:Static vm:TimetableLessonDestination.Move}"
|
||||||
|
Click="OnWeekCellMenuItemClick"/>
|
||||||
<MenuItem Header="🪑 Sitzplan"
|
<MenuItem Header="🪑 Sitzplan"
|
||||||
Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}"
|
Tag="{x:Static vm:TimetableLessonDestination.SeatingPlan}"
|
||||||
Click="OnWeekCellMenuItemClick"/>
|
Click="OnWeekCellMenuItemClick"/>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public partial class TimetableView : UserControl
|
|||||||
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
|
vm.OnImportWebUntisTimetable = ShowWebUntisTimetableDialog;
|
||||||
vm.OnOpenLessonViewer = ShowLessonViewerDialog;
|
vm.OnOpenLessonViewer = ShowLessonViewerDialog;
|
||||||
vm.OnOpenTeachingMode = ShowTeachingMode;
|
vm.OnOpenTeachingMode = ShowTeachingMode;
|
||||||
|
vm.OnCreateLesson = ShowCreateLessonDialog;
|
||||||
|
vm.OnMoveLesson = ShowMoveLessonDialog;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +35,11 @@ public partial class TimetableView : UserControl
|
|||||||
/// Stelle gelöst, in der "Heute"-Tagesliste statt im Wochenraster): im Wochenraster oben
|
/// Stelle gelöst, in der "Heute"-Tagesliste statt im Wochenraster): im Wochenraster oben
|
||||||
/// führt ein Klick auf eine Stunden-Kachel bislang entweder in den Planungsviewer oder zur
|
/// führt ein Klick auf eine Stunden-Kachel bislang entweder in den Planungsviewer oder zur
|
||||||
/// Einheitenplanung — je nachdem, ob schon eine Lesson existiert, ohne dass das von außen
|
/// Einheitenplanung — je nachdem, ob schon eine Lesson existiert, ohne dass das von außen
|
||||||
/// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen vier Zielen als
|
/// erkennbar wäre. Popup-Menü (MenuFlyout, kein ComboBox mehr) mit allen Zielen einschließlich
|
||||||
|
/// Direktanlage und Verschieben als
|
||||||
/// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/>
|
/// Alternative zum Direktklick, siehe <see cref="TimetableViewModel.OpenWeekCellCommand"/>
|
||||||
/// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst
|
/// für den "einheitlicheren" Standard-Klick (Unterrichtsansicht bei laufender Stunde, sonst
|
||||||
/// Planungsviewer, sonst Einheitenplanung). MenuItem.Click statt Command-Binding: ein
|
/// Planungsviewer, sonst Direktanlage). MenuItem.Click statt Command-Binding: ein
|
||||||
/// $parent[ItemsControl]-Vorfahrenpfad (wie beim Zeilen-Button) funktioniert innerhalb eines
|
/// $parent[ItemsControl]-Vorfahrenpfad (wie beim Zeilen-Button) funktioniert innerhalb eines
|
||||||
/// Flyouts nicht zuverlässig, weil dessen Popup nicht im normalen visuellen Baum hängt (siehe
|
/// Flyouts nicht zuverlässig, weil dessen Popup nicht im normalen visuellen Baum hängt (siehe
|
||||||
/// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen,
|
/// TODO.md-Nachtrag zum Klassenlehrer-Bereich) — DataContext-Vererbung (kein Pfad-Suchen,
|
||||||
@@ -58,6 +61,14 @@ public partial class TimetableView : UserControl
|
|||||||
if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null)
|
if (cell.Lesson is { } viewLesson && vm.OnOpenLessonViewer is not null)
|
||||||
await vm.OnOpenLessonViewer(viewLesson);
|
await vm.OnOpenLessonViewer(viewLesson);
|
||||||
break;
|
break;
|
||||||
|
case TimetableLessonDestination.Create:
|
||||||
|
if (cell.Date is { } createDate && vm.OnCreateLesson is not null)
|
||||||
|
await vm.OnCreateLesson(new TimetableLessonRequest(cell.GroupId, createDate, cell.PeriodNumber));
|
||||||
|
break;
|
||||||
|
case TimetableLessonDestination.Move:
|
||||||
|
if (cell.Lesson is { } moveLesson && vm.OnMoveLesson is not null)
|
||||||
|
await vm.OnMoveLesson(new TimetableLessonMoveRequest(moveLesson, cell.PeriodNumber));
|
||||||
|
break;
|
||||||
case TimetableLessonDestination.Planning:
|
case TimetableLessonDestination.Planning:
|
||||||
if (cell.GroupId != Guid.Empty)
|
if (cell.GroupId != Guid.Empty)
|
||||||
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6);
|
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToGroupDetail(cell.GroupId, 6);
|
||||||
@@ -69,6 +80,96 @@ public partial class TimetableView : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ShowCreateLessonDialog(TimetableLessonRequest request)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
var groups = App.Services.GetRequiredService<IGroupRepository>();
|
||||||
|
var group = groups.GetById(request.GroupId);
|
||||||
|
if (owner is null || group is null || !group.IsActive) return;
|
||||||
|
|
||||||
|
var lessons = App.Services.GetRequiredService<ILessonRepository>();
|
||||||
|
if (lessons.GetByGroupAndDate(request.GroupId, request.Date)
|
||||||
|
.Any(l => l.LessonNumber == request.PeriodNumber))
|
||||||
|
{
|
||||||
|
App.Services.GetRequiredService<NotificationService>()
|
||||||
|
.ShowError("Für diesen Termin existiert bereits eine Stundenplanung.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var units = App.Services.GetRequiredService<IUnitRepository>();
|
||||||
|
var pickerVm = new TimetableUnitPickerViewModel(units, group.Id, group.Name,
|
||||||
|
request.Date, request.PeriodNumber);
|
||||||
|
var picker = new TimetableUnitPickerDialog { DataContext = pickerVm };
|
||||||
|
if (!await picker.ShowDialog<bool>(owner) || pickerVm.Result is not { } unit) return;
|
||||||
|
|
||||||
|
var groupLessons = units.GetByGroup(group.Id).SelectMany(u => lessons.GetByUnit(u.Id)).ToList();
|
||||||
|
var materials = groupLessons.SelectMany(l => l.Phases).Select(p => p.Material)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||||
|
var shorthands = groupLessons.SelectMany(l => l.Phases).Select(p => p.Shorthand)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||||
|
var subject = group.SubjectId is { } subjectId
|
||||||
|
? App.Services.GetRequiredService<ISubjectRepository>().GetById(subjectId) : null;
|
||||||
|
|
||||||
|
var lessonVm = new LessonDialogViewModel(
|
||||||
|
lessons,
|
||||||
|
App.Services.GetRequiredService<IShorthandCodeRepository>(),
|
||||||
|
App.Services.GetRequiredService<IAlternativeLessonPathRepository>(),
|
||||||
|
App.Services.GetRequiredService<ITimetableSlotRepository>(),
|
||||||
|
App.Services.GetRequiredService<PeriodScheduleService>(),
|
||||||
|
App.Services.GetRequiredService<IAttachmentStorage>(),
|
||||||
|
unit.Id, group.Id, group.Name, subject?.Name ?? "", materials, shorthands,
|
||||||
|
editingLesson: null, suggestedDate: request.Date, suggestedPeriod: request.PeriodNumber);
|
||||||
|
var lessonDialog = new LessonDialog { DataContext = lessonVm };
|
||||||
|
if (await lessonDialog.ShowDialog<bool>(owner) && lessonVm.Result is not null)
|
||||||
|
{
|
||||||
|
if (pickerVm.ResultIsNew) units.Save(unit);
|
||||||
|
(DataContext as TimetableViewModel)?.Load();
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowSuccess("Stunde angelegt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowMoveLessonDialog(TimetableLessonMoveRequest request)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null) return;
|
||||||
|
var lesson = request.Lesson;
|
||||||
|
var slots = App.Services.GetRequiredService<ITimetableSlotRepository>();
|
||||||
|
var schedule = App.Services.GetRequiredService<PeriodScheduleService>();
|
||||||
|
var anchor = lesson.LessonNumber;
|
||||||
|
var firstPartMinutes = anchor is int p ? schedule.GetDurationMinutes(p) : 0;
|
||||||
|
var isDouble = anchor is int start && firstPartMinutes > 0 &&
|
||||||
|
slots.GetByGroup(lesson.GroupId).Any(s => s.Weekday == lesson.Date.DayOfWeek &&
|
||||||
|
s.PeriodNumber == start + 1) && lesson.Phases.Sum(x => x.DurationMinutes) > firstPartMinutes;
|
||||||
|
|
||||||
|
var dialogVm = new MoveLessonDialogViewModel(lesson.Date, lesson.LessonNumber, isDouble,
|
||||||
|
isDouble && anchor is int a && request.SelectedPeriod > a);
|
||||||
|
var dialog = new MoveLessonDialog { DataContext = dialogVm };
|
||||||
|
if (!await dialog.ShowDialog<bool>(owner) || dialogVm.Result is not { } target) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = new LessonSchedulingService(App.Services.GetRequiredService<ILessonRepository>());
|
||||||
|
if (target.SplitDoubleLesson)
|
||||||
|
{
|
||||||
|
if (target.NewPeriod is not int newPeriod)
|
||||||
|
throw new InvalidOperationException("Für den zweiten Teil ist eine Zielstunde erforderlich.");
|
||||||
|
service.SplitAndMoveSecondPart(lesson, firstPartMinutes, target.NewDate, newPeriod,
|
||||||
|
schedule.GetTimes(newPeriod)?.Start);
|
||||||
|
}
|
||||||
|
else service.Move(lesson, target.NewDate, target.NewPeriod, target.ShiftFollowing);
|
||||||
|
|
||||||
|
(DataContext as TimetableViewModel)?.Load();
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
|
||||||
|
target.SplitDoubleLesson ? "Doppelstunde getrennt und Fortsetzung verschoben." : "Stunde verschoben.");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ShowWebUntisTimetableDialog()
|
private async Task ShowWebUntisTimetableDialog()
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|||||||
@@ -1615,13 +1615,9 @@ zurückgestellt):
|
|||||||
einer Doppelstunde also die erste Periode, dieselbe Ankerkonvention wie bei 4.2.5) — über
|
einer Doppelstunde also die erste Periode, dieselbe Ankerkonvention wie bei 4.2.5) — über
|
||||||
den bereits bestehenden `OnLessonNumberChanged`-Hook wird dadurch automatisch auch der
|
den bereits bestehenden `OnLessonNumberChanged`-Hook wird dadurch automatisch auch der
|
||||||
Stundenbeginn aus dem Stundenraster übernommen, ganz ohne zusätzlichen Code.
|
Stundenbeginn aus dem Stundenraster übernommen, ganz ohne zusätzlichen Code.
|
||||||
- [ ] **4.5.2** Vom Stundenplan (Wochenraster oder Bearbeiten-Raster) direkt in einen
|
- [x] **4.5.2** Vom Stundenplan (Wochenraster oder Tagesliste) direkt in einen
|
||||||
"Viewer" der zugehörigen `Lesson` springen können — und von dort auch eine neue `Lesson`
|
"Viewer" der zugehörigen `Lesson` springen können — und von dort auch eine neue `Lesson`
|
||||||
anlegen können. **Offene Frage, vor Umsetzung zu klären:** `Lesson` hängt an einer
|
anlegen können.
|
||||||
übergeordneten `Unit` (4.1) — beim Anlegen direkt aus dem Stundenplan heraus ist unklar,
|
|
||||||
welcher `Unit` die neue Lesson zugeordnet werden soll (aktuellste offene Einheit der Gruppe?
|
|
||||||
Rückfrage an die Lehrkraft?). Muss überdacht werden, bevor das gebaut wird — deshalb weiterhin
|
|
||||||
unimplementiert, auch nach diesem Durchgang.
|
|
||||||
|
|
||||||
**Teilweise umgesetzt:** Der erste Teil ("direkt in den Viewer springen") ist jetzt fertig,
|
**Teilweise umgesetzt:** Der erste Teil ("direkt in den Viewer springen") ist jetzt fertig,
|
||||||
sofern für den angeklickten Slot schon eine `Lesson` existiert — genau der unstrittige Teil
|
sofern für den angeklickten Slot schon eine `Lesson` existiert — genau der unstrittige Teil
|
||||||
@@ -1630,9 +1626,11 @@ zurückgestellt):
|
|||||||
— dessen Klick öffnet weiterhin `TimetableSlotDialog` zur Slot-Zuweisung, das ist ein anderer,
|
— dessen Klick öffnet weiterhin `TimetableSlotDialog` zur Slot-Zuweisung, das ist ein anderer,
|
||||||
etablierter Zweck) öffnen bei vorhandener Lesson jetzt direkt `LessonViewerDialog` statt nur
|
etablierter Zweck) öffnen bei vorhandener Lesson jetzt direkt `LessonViewerDialog` statt nur
|
||||||
grob zum Planung-Tab der Gruppe zu springen (`TimetableViewModel.OnOpenLessonViewer`,
|
grob zum Planung-Tab der Gruppe zu springen (`TimetableViewModel.OnOpenLessonViewer`,
|
||||||
`TodayLessonItem`/`WeekCellItem.Lesson`). Ohne vorhandene Lesson bleibt es unverändert bei der
|
`TodayLessonItem`/`WeekCellItem.Lesson`). **Jetzt vollständig umgesetzt:** Ohne vorhandene
|
||||||
Navigation zum Planung-Tab — dort lässt sich über "+ Stunde" weiterhin manuell mit expliziter
|
Lesson startet der Klick die Direktanlage mit exakt vorbelegtem Datum und exakter Periode. Ein
|
||||||
Unit-Auswahl eine neue Lesson anlegen.
|
vorgeschalteter Dialog sortiert laufende Einheiten zuerst, lässt aber auch direkt eine neue
|
||||||
|
Einheit anlegen; damit ist die frühere offene Unit-Frage ohne stillschweigende Zuordnung gelöst.
|
||||||
|
Neue Direktanlagen starten als `Entwurf`.
|
||||||
- [x] **4.5.3** Aus diesem Lesson-Viewer heraus weiter verzweigen können: in die Zeugnisnote/
|
- [x] **4.5.3** Aus diesem Lesson-Viewer heraus weiter verzweigen können: in die Zeugnisnote/
|
||||||
Bewertung der Gruppe, und in die Schnelldialoge für Mitarbeit sowie Anwesenheit/Hausaufgaben.
|
Bewertung der Gruppe, und in die Schnelldialoge für Mitarbeit sowie Anwesenheit/Hausaufgaben.
|
||||||
**Umsetzung:** Zwei neue Buttons im Footer von `LessonViewerDialog` ("Zur Mitarbeit"/
|
**Umsetzung:** Zwei neue Buttons im Footer von `LessonViewerDialog` ("Zur Mitarbeit"/
|
||||||
@@ -1668,11 +1666,17 @@ zurückgestellt):
|
|||||||
Editor neben dem Hausaufgabenfeld — genau dort, wo `Homework` ohnehin schon gepflegt wird.
|
Editor neben dem Hausaufgabenfeld — genau dort, wo `Homework` ohnehin schon gepflegt wird.
|
||||||
Klick auf die Stunde im Stundenplan navigiert wie gehabt zur Lerngruppe/Planung, von dort
|
Klick auf die Stunde im Stundenplan navigiert wie gehabt zur Lerngruppe/Planung, von dort
|
||||||
ist die betroffene (vorherige) Stunde ein Klick entfernt.
|
ist die betroffene (vorherige) Stunde ein Klick entfernt.
|
||||||
- [ ] **4.5.5** Stunden aus dieser Ansicht heraus verschieben können, wenn kurzfristig etwas
|
- [x] **4.5.5** Stunden aus dieser Ansicht heraus verschieben können, wenn kurzfristig etwas
|
||||||
dazwischenkommt.
|
dazwischenkommt. **Umsetzung:** Das Kachelmenü bietet Datum und Zielperiode an; belegte
|
||||||
- [ ] **4.5.6** Bei Doppelstunden (90-Minuten-Planung laut Verlaufsplan, 4.2.2) den Inhalt
|
Zieltermine werden abgewiesen. Optional rücken alle späteren, noch nicht durchgeführten
|
||||||
|
Stunden der Einheit um dasselbe Datumsdelta nach.
|
||||||
|
- [x] **4.5.6** Bei Doppelstunden (90-Minuten-Planung laut Verlaufsplan, 4.2.2) den Inhalt
|
||||||
sinnvoll auf die beiden Kacheln/Perioden aufteilen, damit z.B. gezielt nur die zweite Stunde
|
sinnvoll auf die beiden Kacheln/Perioden aufteilen, damit z.B. gezielt nur die zweite Stunde
|
||||||
eines Blocks verschoben werden kann, ohne den ganzen Block anzufassen.
|
eines Blocks verschoben werden kann, ohne den ganzen Block anzufassen. **Umsetzung:** Der
|
||||||
|
Stundenplan erkennt eine Doppelstunde aus aufeinanderfolgenden Slots, Stundenraster und
|
||||||
|
Phasendauer. Beim Verschieben der zweiten Kachel ist "nur zweiten Teil" vorausgewählt; die
|
||||||
|
Phasen werden exakt an der Dauer der ersten Periode geteilt (auch mitten in einer Phase),
|
||||||
|
Hausaufgabe und Reflexion wandern in die neu erzeugte Fortsetzungsstunde.
|
||||||
|
|
||||||
**Architekturentscheidung (Nachtrag, Konzeptgespräch):** Diskutiert wurde, ob eine eigene
|
**Architekturentscheidung (Nachtrag, Konzeptgespräch):** Diskutiert wurde, ob eine eigene
|
||||||
Multiplattform-App für die Einheiten-/Stundenplanung sinnvoll ist — analog zum separaten
|
Multiplattform-App für die Einheiten-/Stundenplanung sinnvoll ist — analog zum separaten
|
||||||
|
|||||||
Reference in New Issue
Block a user